mixdog 0.9.38 → 0.9.39
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/package.json +9 -4
- package/scripts/abort-recovery-test.mjs +43 -2
- package/scripts/agent-tag-reuse-smoke.mjs +146 -6
- package/scripts/agent-terminal-reap-test.mjs +127 -0
- package/scripts/agent-trace-io-test.mjs +69 -0
- package/scripts/code-graph-disk-hit-test.mjs +224 -0
- package/scripts/execution-completion-dedup-test.mjs +157 -0
- package/scripts/execution-pending-resume-kick-test.mjs +57 -2
- package/scripts/execution-resume-esc-integration-test.mjs +174 -0
- package/scripts/explore-bench.mjs +38 -2
- package/scripts/explore-prompt-policy-test.mjs +152 -11
- package/scripts/find-fuzzy-hidden-test.mjs +122 -0
- package/scripts/internal-comms-bench-test.mjs +226 -0
- package/scripts/internal-comms-bench.mjs +185 -58
- package/scripts/internal-comms-smoke.mjs +171 -23
- package/scripts/live-worker-smoke.mjs +38 -2
- package/scripts/memory-cycle-routing-test.mjs +111 -0
- package/scripts/memory-rule-contract-test.mjs +93 -0
- package/scripts/output-style-smoke.mjs +2 -2
- package/scripts/rg-runner-test.mjs +240 -0
- package/scripts/routing-corpus-test.mjs +349 -0
- package/scripts/routing-corpus.mjs +211 -32
- package/scripts/session-orphan-sweep-test.mjs +83 -0
- package/scripts/steering-drain-buckets-test.mjs +179 -0
- package/scripts/tool-smoke.mjs +21 -13
- package/scripts/tool-tui-presentation-test.mjs +202 -0
- package/src/agents/heavy-worker/AGENT.md +10 -7
- package/src/agents/reviewer/AGENT.md +6 -4
- package/src/agents/worker/AGENT.md +7 -5
- package/src/rules/agent/00-common.md +4 -4
- package/src/rules/agent/00-core.md +11 -14
- package/src/rules/agent/20-skip-protocol.md +3 -3
- package/src/rules/agent/30-explorer.md +50 -60
- package/src/rules/agent/40-cycle1-agent.md +15 -24
- package/src/rules/agent/41-cycle2-agent.md +33 -57
- package/src/rules/agent/42-cycle3-agent.md +28 -42
- package/src/rules/lead/01-general.md +7 -10
- package/src/rules/lead/lead-brief.md +11 -14
- package/src/rules/lead/lead-tool.md +6 -5
- package/src/rules/shared/01-tool.md +44 -45
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +37 -1
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +41 -2
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/store.mjs +223 -42
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
- package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
- package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
- package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
- package/src/runtime/shared/tool-primitives.mjs +4 -1
- package/src/runtime/shared/tool-status.mjs +27 -0
- package/src/runtime/shared/tool-surface.mjs +6 -3
- package/src/session-runtime/config-helpers.mjs +14 -0
- package/src/session-runtime/context-status.mjs +1 -0
- package/src/session-runtime/effort.mjs +6 -2
- package/src/session-runtime/model-recency.mjs +5 -2
- package/src/session-runtime/runtime-core.mjs +35 -2
- package/src/session-runtime/tool-catalog.mjs +34 -0
- package/src/standalone/agent-tool/notify.mjs +13 -0
- package/src/standalone/agent-tool.mjs +45 -69
- package/src/standalone/explore-tool.mjs +6 -7
- package/src/tui/App.jsx +31 -0
- package/src/tui/app/model-options.mjs +5 -3
- package/src/tui/app/model-picker.mjs +12 -24
- package/src/tui/app/transcript-window.mjs +1 -0
- package/src/tui/components/ToolExecution.jsx +11 -6
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
- package/src/tui/components/tool-execution/text-format.mjs +10 -19
- package/src/tui/dist/index.mjs +517 -142
- package/src/tui/engine/agent-job-feed.mjs +144 -17
- package/src/tui/engine/agent-response-tail.mjs +68 -0
- package/src/tui/engine/notification-plan.mjs +16 -0
- package/src/tui/engine/session-api.mjs +8 -2
- package/src/tui/engine/session-flow.mjs +19 -1
- package/src/tui/engine/tool-card-results.mjs +54 -32
- package/src/tui/engine/tool-result-status.mjs +75 -21
- package/src/tui/engine/turn.mjs +77 -42
- package/src/tui/engine.mjs +63 -2
- package/src/workflows/bench/WORKFLOW.md +25 -35
- package/src/workflows/default/WORKFLOW.md +38 -32
- package/src/workflows/solo/WORKFLOW.md +19 -22
- package/scripts/_jitter-fuzz.mjs +0 -44410
- package/scripts/_jitter-fuzz2.mjs +0 -44400
- package/scripts/_jitter-probe.mjs +0 -44397
- package/scripts/_jp2.mjs +0 -45614
|
@@ -8,12 +8,13 @@ import * as fsp from 'fs/promises';
|
|
|
8
8
|
import { randomBytes } from 'crypto';
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
import { Worker } from 'worker_threads';
|
|
11
|
-
import { getPluginData } from '../config.mjs';
|
|
11
|
+
import { getPluginData, loadConfig } from '../config.mjs';
|
|
12
12
|
import { isAgentOwner } from '../agent-owner.mjs';
|
|
13
13
|
import { renameWithRetrySync } from '../../../shared/atomic-file.mjs';
|
|
14
14
|
import { sanitizeContentForStoredHistory } from '../providers/media-normalization.mjs';
|
|
15
15
|
import { scanTopLevelLifecycle } from './lifecycle-scan.mjs';
|
|
16
16
|
import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '../../../../lib/mixdog-debug.cjs';
|
|
17
|
+
import { resolveAgentTerminalReapMs } from '../../../../session-runtime/config-helpers.mjs';
|
|
17
18
|
import {
|
|
18
19
|
summaryIndexPath,
|
|
19
20
|
_sessionSummary,
|
|
@@ -600,8 +601,37 @@ export function markSessionClosed(id, reason = 'manual') {
|
|
|
600
601
|
_clearDebounce(id);
|
|
601
602
|
const existing = loadSession(id);
|
|
602
603
|
if (!existing) return null;
|
|
603
|
-
|
|
604
|
-
|
|
604
|
+
// Re-close idempotence: a session that is ALREADY tombstoned keeps its
|
|
605
|
+
// ORIGINAL close time (updatedAt) and generation. The old code refreshed
|
|
606
|
+
// updatedAt=Date.now() on every call, so the 5-min idle sweep re-closing a
|
|
607
|
+
// stale summary row reset the tombstone age each cycle — tombstones never
|
|
608
|
+
// matured past the sweep threshold (immortality loop). Preserving the
|
|
609
|
+
// original close time lets the age accumulate so the tombstone sweep can
|
|
610
|
+
// reclaim it.
|
|
611
|
+
//
|
|
612
|
+
// The alreadyClosed / original-close-time / generation decision MUST come
|
|
613
|
+
// from the ON-DISK JSON, read cache-bypassing — NOT from loadSession(),
|
|
614
|
+
// which can serve a stale in-memory OPEN payload (a pending debounced save
|
|
615
|
+
// or a _liveSessions entry) after a late save. Deciding off that stale open
|
|
616
|
+
// copy would make a re-close of an already-tombstoned session look like a
|
|
617
|
+
// FIRST close and reset updatedAt+generation, resurrecting the exact
|
|
618
|
+
// immortality refresh this guard prevents. The disk file is the
|
|
619
|
+
// authoritative tombstone state.
|
|
620
|
+
let onDisk = null;
|
|
621
|
+
try { onDisk = JSON.parse(readFileSync(sessionPath(id), 'utf-8')); }
|
|
622
|
+
catch { onDisk = null; }
|
|
623
|
+
const alreadyClosed = onDisk
|
|
624
|
+
? (onDisk.closed === true || onDisk.status === 'closed')
|
|
625
|
+
: (existing.closed === true);
|
|
626
|
+
// When the on-disk copy is already closed, base the (idempotent) tombstone
|
|
627
|
+
// rewrite on IT rather than on `existing`, so a stale open in-memory
|
|
628
|
+
// payload can never clobber the persisted tombstone's content/fields.
|
|
629
|
+
const base = (alreadyClosed && onDisk) ? onDisk : existing;
|
|
630
|
+
const closeTime = (alreadyClosed && typeof base.updatedAt === 'number' && base.updatedAt > 0)
|
|
631
|
+
? base.updatedAt
|
|
632
|
+
: Date.now();
|
|
633
|
+
const newGen = (typeof base.generation === 'number' ? base.generation : 0) + (alreadyClosed ? 0 : 1);
|
|
634
|
+
const tombstone = { ...base, closed: true, closedReason: alreadyClosed ? (base.closedReason || reason) : reason, status: 'closed', generation: newGen, updatedAt: closeTime };
|
|
605
635
|
// Bypass the queue + guard — this IS the tombstone write.
|
|
606
636
|
const target = sessionPath(id);
|
|
607
637
|
const tmp = target + '.' + randomBytes(6).toString('hex') + '.tmp';
|
|
@@ -622,7 +652,10 @@ export function markSessionClosed(id, reason = 'manual') {
|
|
|
622
652
|
// it reflects the session's full lifetime including the close turn.
|
|
623
653
|
try {
|
|
624
654
|
const _dataDir = getPluginData();
|
|
625
|
-
|
|
655
|
+
// Emit the close metric only on the FIRST close — a re-close of an
|
|
656
|
+
// already-tombstoned session is a no-op idempotent write and must not
|
|
657
|
+
// spam the close log or double-count lifetimes.
|
|
658
|
+
if (_dataDir && !alreadyClosed) {
|
|
626
659
|
const _ts = new Date().toISOString();
|
|
627
660
|
const _lifeMs = (typeof existing.createdAt === 'number' && existing.createdAt > 0)
|
|
628
661
|
? (tombstone.updatedAt - existing.createdAt)
|
|
@@ -722,16 +755,24 @@ export function deleteSession(id, options = {}) {
|
|
|
722
755
|
return removed;
|
|
723
756
|
}
|
|
724
757
|
const DEFAULT_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes idle — aligned with Anthropic 5m messages tier and OpenAI in-memory cache window
|
|
725
|
-
// Completed agents (idle/done/error) live until terminal reap - must
|
|
726
|
-
// match TERMINAL_REAP_MS / _scheduleBridgeReap (3_600_000) in index.mjs and
|
|
727
|
-
// agent stall watchdog, so the store sweep is the durable 1h reaper.
|
|
728
|
-
const AGENT_TERMINAL_TTL_MS = 60 * 60 * 1000;
|
|
729
758
|
const AGENT_TERMINAL_STATUSES = new Set(['idle', 'done', 'error']);
|
|
730
759
|
// Hard wall-clock ceiling for sessions stuck in status='running'. The
|
|
731
760
|
// stream-watchdog should abort stalled streams within ~120s, but if it misses
|
|
732
761
|
// one (process crash, watchdog not started, provider never returned), this
|
|
733
762
|
// backstop reclaims the file so the sweep doesn't leak zombies indefinitely.
|
|
734
763
|
const RUNNING_STALL_MS = 10 * 60 * 1000;
|
|
764
|
+
// Retention cap for resumable OPEN (non-tombstone) sessions. Lead/user resume
|
|
765
|
+
// closes sessions with { tombstone:false } — the runtime detaches but the
|
|
766
|
+
// session JSON stays open/resumable and is never lifecycle-closed, so without
|
|
767
|
+
// a cap the sessions/ dir grows without bound (observed 782 open files). The
|
|
768
|
+
// sweep prunes open sessions past EITHER bound: older than 14d, or beyond the
|
|
769
|
+
// newest 300 (oldest first). The cap targets ONLY ephemeral agent/ownerless
|
|
770
|
+
// sessions — explicit USER-owned conversations are never auto-pruned (deleting
|
|
771
|
+
// a user's history, including the current foreground session which is idle
|
|
772
|
+
// during a gated sweep, is unacceptable). A session with a live runtime entry
|
|
773
|
+
// (options.isSessionLive) is additionally protected as defense-in-depth.
|
|
774
|
+
const RESUMABLE_OPEN_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
|
775
|
+
const RESUMABLE_OPEN_MAX_COUNT = 300;
|
|
735
776
|
|
|
736
777
|
export function listStoredSessions() {
|
|
737
778
|
const dir = getStoreDir();
|
|
@@ -800,12 +841,44 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
800
841
|
}
|
|
801
842
|
const maxAge = ttlMs || DEFAULT_SESSION_TTL_MS;
|
|
802
843
|
const sweepIdle = options.sweepIdle !== false;
|
|
844
|
+
let terminalReapConfig = null;
|
|
845
|
+
try { terminalReapConfig = loadConfig({ secrets: false }); } catch { /* built-ins remain available */ }
|
|
803
846
|
const tombstoneMaxAgeMs = Number(options.tombstoneMaxAgeMs);
|
|
804
847
|
const sweepTombstones = Number.isFinite(tombstoneMaxAgeMs) && tombstoneMaxAgeMs > 0;
|
|
848
|
+
// Retention cap for resumable open sessions runs only on the idle sweep
|
|
849
|
+
// (never on a tombstone-only pass). isSessionLive protects the current /
|
|
850
|
+
// actively-running sessions from being pruned by the retention cap.
|
|
851
|
+
const isSessionLive = typeof options.isSessionLive === 'function' ? options.isSessionLive : null;
|
|
852
|
+
const retainOpen = sweepIdle && options.retainOpenSessions !== false;
|
|
853
|
+
const _optAge = Number(options.openMaxAgeMs);
|
|
854
|
+
const _optCount = Number(options.openMaxCount);
|
|
855
|
+
const openMaxAgeMs = Number.isFinite(_optAge) && _optAge > 0 ? _optAge : RESUMABLE_OPEN_MAX_AGE_MS;
|
|
856
|
+
const openMaxCount = Number.isFinite(_optCount) && _optCount >= 0 ? _optCount : RESUMABLE_OPEN_MAX_COUNT;
|
|
805
857
|
const dir = getStoreDir();
|
|
806
858
|
if (!existsSync(dir))
|
|
807
859
|
return { cleaned: 0, remaining: 0, details: [], tombstonesCleaned: 0, tombstoneDetails: [], tombstoneErrors: [] };
|
|
808
|
-
|
|
860
|
+
// Reconcile the index-derived candidate set with a direct directory scan:
|
|
861
|
+
// the summary index is a best-effort sidecar that can lag far behind disk
|
|
862
|
+
// (thousands of on-disk .json files may be absent from a smaller index).
|
|
863
|
+
// Any such orphan closed+mature tombstone would otherwise be unreachable by
|
|
864
|
+
// this sweep and accumulate forever. Union the index rows with every
|
|
865
|
+
// on-disk .json id, deduped by id; synthetic { id } rows are sufficient
|
|
866
|
+
// because the loop below re-reads all lifecycle truth from disk. This stays
|
|
867
|
+
// sweep-local and does NOT change listStoredSessionSummaries for other
|
|
868
|
+
// callers. Steady-state cost is one readdirSync plus cheap per-orphan reads.
|
|
869
|
+
const indexRows = listStoredSessionSummaries();
|
|
870
|
+
const summaries = indexRows;
|
|
871
|
+
try {
|
|
872
|
+
const seen = new Set();
|
|
873
|
+
for (const row of indexRows) { if (row?.id) seen.add(row.id); }
|
|
874
|
+
for (const f of readdirSync(dir)) {
|
|
875
|
+
if (!f.endsWith('.json')) continue;
|
|
876
|
+
const id = f.slice(0, -5);
|
|
877
|
+
if (!id || seen.has(id)) continue;
|
|
878
|
+
seen.add(id);
|
|
879
|
+
summaries.push({ id });
|
|
880
|
+
}
|
|
881
|
+
} catch { /* dir scan failure — fall back to index rows only */ }
|
|
809
882
|
const now = Date.now();
|
|
810
883
|
let cleaned = 0;
|
|
811
884
|
let remaining = 0;
|
|
@@ -813,6 +886,11 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
813
886
|
const details = [];
|
|
814
887
|
const tombstoneDetails = [];
|
|
815
888
|
const tombstoneErrors = [];
|
|
889
|
+
// Retention-cap bookkeeping: collect surviving open (non-tombstone)
|
|
890
|
+
// sessions here, then prune oldest-first after the main loop.
|
|
891
|
+
const openCandidates = [];
|
|
892
|
+
let openPruned = 0;
|
|
893
|
+
const openPrunedDetails = [];
|
|
816
894
|
for (const row of summaries) {
|
|
817
895
|
try {
|
|
818
896
|
if (!row?.id) continue;
|
|
@@ -828,38 +906,116 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
828
906
|
const hbPath = join(dir, `${row.id}.hb`);
|
|
829
907
|
if (existsSync(hbPath)) heartbeatMtime = statSync(hbPath).mtimeMs || 0;
|
|
830
908
|
} catch { /* .hb unavailable — fall back to JSON fields */ }
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
909
|
+
// Truth source: the summary index is a deferred/best-effort sidecar,
|
|
910
|
+
// so a row can still claim status='idle'/open while the session JSON
|
|
911
|
+
// was already tombstoned. Read the real session JSON BEFORE the
|
|
912
|
+
// freshness gate so closed-ness is decided from AUTHORITATIVE on-disk
|
|
913
|
+
// state — otherwise idle-sweep re-closes an already-closed session via
|
|
914
|
+
// markSessionClosed (which, pre-fix, reset the tombstone age every
|
|
915
|
+
// 5-min cycle → immortality loop).
|
|
916
|
+
let raw = null;
|
|
917
|
+
try { raw = readFileSync(jsonPath, 'utf-8'); }
|
|
918
|
+
catch { /* racing unlink / transient read failure */ }
|
|
919
|
+
let actual = null;
|
|
920
|
+
let diskClosed;
|
|
921
|
+
if (raw == null) {
|
|
922
|
+
diskClosed = (row.closed === true || row.status === 'closed');
|
|
923
|
+
} else {
|
|
924
|
+
// Cheap top-level scan avoids allocating the whole messages array
|
|
925
|
+
// just to read the closed flag for the (common) fresh open
|
|
926
|
+
// session the gate will skip; full-parse only when the scan can't
|
|
927
|
+
// resolve the top-level flag.
|
|
928
|
+
const scan = scanTopLevelLifecycle(raw);
|
|
929
|
+
if (scan && typeof scan.closed === 'boolean') {
|
|
930
|
+
diskClosed = scan.closed;
|
|
931
|
+
} else {
|
|
932
|
+
try { actual = JSON.parse(raw); } catch { actual = null; }
|
|
933
|
+
diskClosed = actual
|
|
934
|
+
? (actual.closed === true || actual.status === 'closed')
|
|
935
|
+
: (row.closed === true || row.status === 'closed');
|
|
936
|
+
}
|
|
836
937
|
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
tombstoneErrors.push({ id: row.id, message: err?.message || String(err) });
|
|
852
|
-
remaining++;
|
|
938
|
+
if (diskClosed) {
|
|
939
|
+
// Closed sessions are EXEMPT from the freshness gate: a tombstone
|
|
940
|
+
// whose file/hb mtime keeps getting bumped would otherwise stay
|
|
941
|
+
// perpetually "fresh" and never mature. Maturity is governed ONLY
|
|
942
|
+
// by the ORIGINAL close time (disk updatedAt, not row.updatedAt
|
|
943
|
+
// which a stale row may carry from before the close).
|
|
944
|
+
if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
|
|
945
|
+
const closedAt = Number(actual?.updatedAt ?? row.updatedAt);
|
|
946
|
+
const age = now - closedAt;
|
|
947
|
+
if (sweepTombstones && Number.isFinite(closedAt) && age >= tombstoneMaxAgeMs) {
|
|
948
|
+
try {
|
|
949
|
+
if (deleteSession(row.id, { deferSummaryUpdate: true })) {
|
|
950
|
+
tombstonesCleaned++;
|
|
951
|
+
tombstoneDetails.push({ id: row.id, ageSeconds: Math.floor(age / 1000) });
|
|
853
952
|
continue;
|
|
854
953
|
}
|
|
954
|
+
} catch (err) {
|
|
955
|
+
tombstoneErrors.push({ id: row.id, message: err?.message || String(err) });
|
|
956
|
+
remaining++;
|
|
957
|
+
continue;
|
|
855
958
|
}
|
|
856
959
|
}
|
|
960
|
+
// Repair a stale summary row that still claimed the session was
|
|
961
|
+
// open: reflect the real closed state so the next sweep sees the
|
|
962
|
+
// correct closed=true/updatedAt and never re-closes it.
|
|
963
|
+
if (actual && !(row.closed === true || row.status === 'closed')) {
|
|
964
|
+
try { _upsertSessionSummary(actual); } catch { /* best-effort */ }
|
|
965
|
+
}
|
|
966
|
+
remaining++;
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
// Parse the open record before its freshness gate: completed agents
|
|
970
|
+
// use their provider's Advanced terminal duration rather than the
|
|
971
|
+
// general sweep cadence. A short provider override must therefore
|
|
972
|
+
// not be hidden behind the default 5-minute gate.
|
|
973
|
+
if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
|
|
974
|
+
const gateOwner = (actual && typeof actual.owner === 'string' && actual.owner.length > 0)
|
|
975
|
+
? actual.owner : row.owner;
|
|
976
|
+
const gateStatus = (actual && typeof actual.status === 'string') ? actual.status : row.status;
|
|
977
|
+
const gateProvider = (actual && typeof actual.provider === 'string') ? actual.provider : row.provider;
|
|
978
|
+
const isCompletedAgentForGate = isAgentOwner({ owner: gateOwner })
|
|
979
|
+
&& AGENT_TERMINAL_STATUSES.has(gateStatus);
|
|
980
|
+
const terminalReapMsForGate = isCompletedAgentForGate
|
|
981
|
+
? resolveAgentTerminalReapMs(terminalReapConfig, gateProvider)
|
|
982
|
+
: null;
|
|
983
|
+
if (isCompletedAgentForGate && terminalReapMsForGate == null) {
|
|
857
984
|
remaining++;
|
|
858
985
|
continue;
|
|
859
986
|
}
|
|
987
|
+
// Freshness gate — OPEN sessions only (closed sessions handled and
|
|
988
|
+
// `continue`d above). Recently-touched open sessions are skipped
|
|
989
|
+
// cheaply here.
|
|
990
|
+
const freshnessGateMs = sweepIdle
|
|
991
|
+
? (terminalReapMsForGate ?? maxAge)
|
|
992
|
+
: (sweepTombstones ? tombstoneMaxAgeMs : 0);
|
|
993
|
+
const newestKnown = Math.max(row.updatedAt || 0, row.lastHeartbeatAt || 0, row.createdAt || 0, jsonMtime, heartbeatMtime);
|
|
994
|
+
if (freshnessGateMs > 0 && newestKnown > 0 && now - newestKnown <= freshnessGateMs) {
|
|
995
|
+
remaining++;
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
// Prefer the AUTHORITATIVE on-disk JSON over the best-effort (and
|
|
999
|
+
// possibly stale) summary row for every open/idle liveness and
|
|
1000
|
+
// ownership decision below — a stale row must not close or prune the
|
|
1001
|
+
// wrong session. Full-parse here if the cheap scan skipped it.
|
|
1002
|
+
if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
|
|
1003
|
+
const effOwner = (actual && typeof actual.owner === 'string' && actual.owner.length > 0)
|
|
1004
|
+
? actual.owner : row.owner;
|
|
1005
|
+
const ownerRef = { owner: effOwner };
|
|
1006
|
+
const effStatus = (actual && typeof actual.status === 'string') ? actual.status : row.status;
|
|
1007
|
+
const effUpdatedAt = Number(actual?.updatedAt) > 0 ? Number(actual.updatedAt) : (row.updatedAt || 0);
|
|
1008
|
+
const effLastHb = Number(actual?.lastHeartbeatAt) > 0 ? Number(actual.lastHeartbeatAt) : (row.lastHeartbeatAt || 0);
|
|
1009
|
+
const effCreatedAt = Number(actual?.createdAt) > 0 ? Number(actual.createdAt) : (row.createdAt || 0);
|
|
1010
|
+
const effBashId = (actual && actual.implicitBashSessionId) || row.implicitBashSessionId || null;
|
|
1011
|
+
const effProvider = (actual && typeof actual.provider === 'string') ? actual.provider : row.provider;
|
|
860
1012
|
// Sweep agent-owned and ownerless (legacy) sessions; skip explicit
|
|
861
|
-
// user sessions before touching heartbeat sidecars.
|
|
862
|
-
|
|
1013
|
+
// user sessions before touching heartbeat sidecars. USER-owned
|
|
1014
|
+
// conversations are NEVER added to the retention-cap candidate set —
|
|
1015
|
+
// the cap must not auto-delete user history (nor the current
|
|
1016
|
+
// foreground session, which is idle during a gated sweep). Only the
|
|
1017
|
+
// ephemeral agent/ownerless sessions below feed the cap.
|
|
1018
|
+
if (typeof effOwner === 'string' && effOwner.length > 0 && !isAgentOwner(ownerRef)) {
|
|
863
1019
|
remaining++;
|
|
864
1020
|
continue;
|
|
865
1021
|
}
|
|
@@ -870,18 +1026,19 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
870
1026
|
// Prefer .hb sidecar mtime — updated at tight cadence (≤5s) without
|
|
871
1027
|
// serialising the full JSON, so it reflects true liveness more
|
|
872
1028
|
// accurately than the JSON timestamp fields.
|
|
873
|
-
let lastActive =
|
|
1029
|
+
let lastActive = effLastHb || effUpdatedAt || effCreatedAt || 0;
|
|
874
1030
|
if (heartbeatMtime) lastActive = Math.max(lastActive, heartbeatMtime);
|
|
875
1031
|
// Running sessions are normally reaped by the stream-watchdog
|
|
876
1032
|
// within ~120s. Skip them here unless they've been silent past
|
|
877
1033
|
// RUNNING_STALL_MS, at which point they are treated as zombies.
|
|
878
|
-
if (
|
|
1034
|
+
if (effStatus === 'running' && now - lastActive <= RUNNING_STALL_MS) {
|
|
879
1035
|
remaining++;
|
|
880
1036
|
continue;
|
|
881
1037
|
}
|
|
882
|
-
const isCompletedAgent = isAgentOwner(
|
|
883
|
-
&& AGENT_TERMINAL_STATUSES.has(
|
|
884
|
-
const
|
|
1038
|
+
const isCompletedAgent = isAgentOwner(ownerRef)
|
|
1039
|
+
&& AGENT_TERMINAL_STATUSES.has(effStatus);
|
|
1040
|
+
const terminalReapMs = isCompletedAgent ? terminalReapMsForGate : null;
|
|
1041
|
+
const sessionMaxAge = terminalReapMs ?? maxAge;
|
|
885
1042
|
if (now - lastActive > sessionMaxAge) {
|
|
886
1043
|
try { markSessionClosed(row.id, 'idle-sweep'); }
|
|
887
1044
|
catch (err) {
|
|
@@ -891,16 +1048,40 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
891
1048
|
cleaned++;
|
|
892
1049
|
details.push({
|
|
893
1050
|
id: row.id,
|
|
894
|
-
owner:
|
|
1051
|
+
owner: effOwner || 'unknown',
|
|
895
1052
|
idleMinutes: Math.round((now - lastActive) / 60000),
|
|
896
|
-
bashSessionId:
|
|
1053
|
+
bashSessionId: effBashId,
|
|
897
1054
|
});
|
|
898
1055
|
} else {
|
|
1056
|
+
if (retainOpen) openCandidates.push({ id: row.id, lastActive });
|
|
899
1057
|
remaining++;
|
|
900
1058
|
}
|
|
901
1059
|
}
|
|
902
1060
|
catch { /* skip corrupt */ }
|
|
903
1061
|
}
|
|
1062
|
+
// ── Retention cap: prune resumable open (non-tombstone) sessions ──────────
|
|
1063
|
+
// Newest-first: keep the most recent openMaxCount, prune anything older than
|
|
1064
|
+
// openMaxAgeMs OR beyond the count. Live/current sessions (isSessionLive)
|
|
1065
|
+
// are never pruned but still occupy a kept slot.
|
|
1066
|
+
if (retainOpen && openCandidates.length > 0) {
|
|
1067
|
+
openCandidates.sort((a, b) => (b.lastActive || 0) - (a.lastActive || 0));
|
|
1068
|
+
let kept = 0;
|
|
1069
|
+
for (const c of openCandidates) {
|
|
1070
|
+
if (isSessionLive && isSessionLive(c.id)) { kept++; continue; }
|
|
1071
|
+
const tooOld = openMaxAgeMs > 0 && now - (c.lastActive || 0) > openMaxAgeMs;
|
|
1072
|
+
const overCount = kept >= openMaxCount;
|
|
1073
|
+
if (!tooOld && !overCount) { kept++; continue; }
|
|
1074
|
+
try {
|
|
1075
|
+
if (deleteSession(c.id, { deferSummaryUpdate: true })) {
|
|
1076
|
+
openPruned++;
|
|
1077
|
+
openPrunedDetails.push({ id: c.id, ageSeconds: Math.floor((now - (c.lastActive || 0)) / 1000) });
|
|
1078
|
+
if (remaining > 0) remaining--;
|
|
1079
|
+
} else {
|
|
1080
|
+
kept++;
|
|
1081
|
+
}
|
|
1082
|
+
} catch { kept++; }
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
904
1085
|
// Orphan .hb reap: a heartbeat sidecar whose .json no longer exists is dead
|
|
905
1086
|
// weight once it is also stale (older than maxAge) — the session JSON was
|
|
906
1087
|
// swept/closed but the .hb lingered (a pre-fix orphaned heartbeat). The
|
|
@@ -920,11 +1101,11 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
920
1101
|
// read-modify-write for the whole sweep instead of one per deleted id
|
|
921
1102
|
// (the index is multi-MB at scale; per-id rewrites made large sweeps
|
|
922
1103
|
// quadratic and stalled boot for seconds).
|
|
923
|
-
if (tombstoneDetails.length > 0) {
|
|
1104
|
+
if (tombstoneDetails.length > 0 || openPrunedDetails.length > 0) {
|
|
924
1105
|
try {
|
|
925
|
-
const deletedIds = new Set(tombstoneDetails.map((d) => d.id));
|
|
1106
|
+
const deletedIds = new Set([...tombstoneDetails, ...openPrunedDetails].map((d) => d.id));
|
|
926
1107
|
_pruneSummaryIndexIds(deletedIds);
|
|
927
1108
|
} catch { /* summary index is best-effort */ }
|
|
928
1109
|
}
|
|
929
|
-
return { cleaned, remaining, details, tombstonesCleaned, tombstoneDetails, tombstoneErrors };
|
|
1110
|
+
return { cleaned, remaining, details, tombstonesCleaned, tombstoneDetails, tombstoneErrors, openPruned, openPrunedDetails };
|
|
930
1111
|
}
|
|
@@ -79,6 +79,11 @@ function clearGrepContextKeys(args, keys) {
|
|
|
79
79
|
/** Lead-facing grep context: canonicalize aliases and clamp explicit values (overrides still apply). */
|
|
80
80
|
export function applyGrepContextLeadPolicy(args) {
|
|
81
81
|
if (!args || typeof args !== 'object' || Array.isArray(args)) return;
|
|
82
|
+
// Idempotent guard: the outer builtin guard (validateBuiltinArgs) and the
|
|
83
|
+
// executor (executeGrepTool) both call this on the SAME args object. The
|
|
84
|
+
// first pass folds aliases onto -A/-B/-C and clamps; a second pass would
|
|
85
|
+
// recompute the same folding for no effect. Skip once applied.
|
|
86
|
+
if (args._grepContextPolicyApplied) return;
|
|
82
87
|
for (const [canonical, keys] of GREP_CONTEXT_KEY_GROUPS) {
|
|
83
88
|
const found = firstNonZeroGrepContextArg(args, keys) || firstGrepContextArg(args, keys);
|
|
84
89
|
if (!found) continue;
|
|
@@ -87,6 +92,12 @@ export function applyGrepContextLeadPolicy(args) {
|
|
|
87
92
|
clearGrepContextKeys(args, keys);
|
|
88
93
|
args[canonical] = shaped;
|
|
89
94
|
}
|
|
95
|
+
// Non-enumerable so it never leaks into arg spreads, cache keys, or output.
|
|
96
|
+
try {
|
|
97
|
+
Object.defineProperty(args, '_grepContextPolicyApplied', {
|
|
98
|
+
value: true, enumerable: false, configurable: true, writable: true,
|
|
99
|
+
});
|
|
100
|
+
} catch { args._grepContextPolicyApplied = true; }
|
|
90
101
|
}
|
|
91
102
|
|
|
92
103
|
function isString(v) {
|
|
@@ -435,7 +446,7 @@ function guardGrep(a) {
|
|
|
435
446
|
const grepMode = a.output_mode || a.mode;
|
|
436
447
|
const hasExplicitCtx = ['-A', '-B', '-C', 'context'].some((k) => grepContextKeyPresent(a, k));
|
|
437
448
|
const isCountOrFiles = grepMode === 'files_with_matches' || grepMode === 'count';
|
|
438
|
-
if (!isCountOrFiles && (grepMode === 'content_with_context' || hasExplicitCtx)) {
|
|
449
|
+
if (!isCountOrFiles && (grepMode == null || grepMode === 'content_with_context' || hasExplicitCtx)) {
|
|
439
450
|
const hl = Number(a.head_limit);
|
|
440
451
|
if (grepContextKeyPresent(a, 'head_limit') && Number.isFinite(hl) && hl > GREP_CTX_HEAD_LIMIT_MAX) {
|
|
441
452
|
a.head_limit = GREP_CTX_HEAD_LIMIT_MAX;
|
|
@@ -39,7 +39,7 @@ export const BUILTIN_TOOLS = [
|
|
|
39
39
|
name: 'read',
|
|
40
40
|
title: 'Mixdog Read',
|
|
41
41
|
annotations: { title: 'Mixdog Read', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false },
|
|
42
|
-
description: 'Read verified file path(s); guessed path → find first
|
|
42
|
+
description: 'Read verified file path(s); guessed path/name → find first. Array-first: batch paths/regions as real arrays in one call; put targets in path[] or {path,offset,limit}[] and read the whole logical unit. Content_with_context is actionable; do not page or re-read returned spans. Not for directory listing.',
|
|
43
43
|
inputSchema: {
|
|
44
44
|
type: 'object',
|
|
45
45
|
properties: {
|
|
@@ -58,29 +58,31 @@ export const BUILTIN_TOOLS = [
|
|
|
58
58
|
offset: { type: 'number', minimum: 0 },
|
|
59
59
|
limit: { type: 'number', minimum: 1 },
|
|
60
60
|
},
|
|
61
|
+
required: ['path'],
|
|
61
62
|
},
|
|
62
63
|
],
|
|
63
64
|
},
|
|
64
65
|
minItems: 1,
|
|
65
66
|
},
|
|
66
67
|
],
|
|
67
|
-
description: 'Verified file path
|
|
68
|
+
description: 'Verified file path; use {path,offset,limit}[] targets. Pass arrays directly; JSON strings are legacy recovery only.',
|
|
68
69
|
},
|
|
69
|
-
offset: { type: 'number', minimum: 0, description: 'Numeric lines to skip
|
|
70
|
-
limit: { type: 'number', minimum: 1, description: 'Numeric max lines
|
|
70
|
+
offset: { type: 'number', minimum: 0, description: 'Numeric lines to skip; 0 starts at line 1. Continue with offset:N.' },
|
|
71
|
+
limit: { type: 'number', minimum: 1, description: 'Numeric max lines after offset.' },
|
|
71
72
|
},
|
|
73
|
+
required: ['path'],
|
|
72
74
|
},
|
|
73
75
|
},
|
|
74
76
|
{
|
|
75
77
|
name: 'shell',
|
|
76
78
|
title: 'Mixdog Shell',
|
|
77
79
|
annotations: { title: 'Mixdog Shell', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, compressible: true },
|
|
78
|
-
description: `Run programs or change system state
|
|
80
|
+
description: `Run programs or change system state; set shell: powershell or bash. Not for reading, listing or searching. Shell/write calls are serial; use ;/&& only for intentional in-command sequencing.${_shellSyntaxCheat} ${TOOL_ASYNC_EXECUTION_CONTRACT}`,
|
|
79
81
|
inputSchema: {
|
|
80
82
|
type: 'object',
|
|
81
83
|
properties: {
|
|
82
84
|
command: { type: 'string', description: 'Command.' },
|
|
83
|
-
cwd: { type: 'string', description: 'Working directory
|
|
85
|
+
cwd: { type: 'string', description: 'Working directory; persists across calls. Omit to reuse; absolute path changes it.' },
|
|
84
86
|
timeout: { type: 'number', description: `Timeout ms. Default ${_shellDefaultTimeoutMs()} (${_shellDefaultTimeoutMs() / 60000} min) when omitted; an explicit value is honored uncapped — long-running commands may set it higher. On timeout the command is MOVED TO BACKGROUND (a task_id you can wait/status/read/cancel) and keeps running instead of being killed; sleep-like commands and MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS opt out (killed with a [timeout] marker). async/background runs with timeout omitted have NO timeout (run until done/cancelled); an explicit timeout is still enforced.` },
|
|
85
87
|
merge_stderr: { type: 'boolean', description: 'Merge stderr.' },
|
|
86
88
|
mode: { type: 'string', enum: ['sync', 'async'], description: executionModeSchemaDescription('sync') },
|
|
@@ -93,12 +95,12 @@ export const BUILTIN_TOOLS = [
|
|
|
93
95
|
name: 'task',
|
|
94
96
|
title: 'Background Task Control',
|
|
95
97
|
annotations: { title: 'Background Task Control', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
96
|
-
description: `
|
|
98
|
+
description: `Control a shell async task_id: actions list/status/read/wait/cancel. ${TOOL_MANUAL_CONTROL_CONTRACT} Not sess_* or agent ids.`,
|
|
97
99
|
inputSchema: {
|
|
98
100
|
type: 'object',
|
|
99
101
|
properties: {
|
|
100
102
|
task_id: { type: 'string', description: 'shell async task_id.' },
|
|
101
|
-
action: { type: 'string', enum: ['list', 'status', 'read', 'wait', 'cancel'], description: '
|
|
103
|
+
action: { type: 'string', enum: ['list', 'status', 'read', 'wait', 'cancel'], description: 'Action; avoid polling loops.' },
|
|
102
104
|
timeout_ms: { type: 'number', description: 'Wait timeout ms.' },
|
|
103
105
|
poll_ms: { type: 'number', description: 'Wait poll ms.' },
|
|
104
106
|
},
|
|
@@ -109,7 +111,7 @@ export const BUILTIN_TOOLS = [
|
|
|
109
111
|
name: 'grep',
|
|
110
112
|
title: 'Mixdog Grep',
|
|
111
113
|
annotations: { title: 'Mixdog Grep', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
112
|
-
description: '
|
|
114
|
+
description: 'Quoted/non-identifier literal or regex→grep. Search in verified scope (project root counts as verified); guessed path fragment → find first. Omitted output_mode and content_with_context are contextual; a nonzero content_with_context result resolves that search concept—act directly, without regex tweaks, narrowing or re-search. Choose files_with_matches/count for existence; Only zero/error results may change tokens or scope. No path "." + guessed src/**.',
|
|
113
115
|
inputSchema: {
|
|
114
116
|
type: 'object',
|
|
115
117
|
properties: {
|
|
@@ -118,14 +120,14 @@ export const BUILTIN_TOOLS = [
|
|
|
118
120
|
{ type: 'string' },
|
|
119
121
|
{ type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
120
122
|
],
|
|
121
|
-
description: 'Text/regex. Array = variants in one call.',
|
|
123
|
+
description: 'Text/regex. Array = variants in one call; pattern[] batches tokens. path[] batches verified scopes only; file/span reads use read path[] regions.',
|
|
122
124
|
},
|
|
123
125
|
path: {
|
|
124
126
|
anyOf: [
|
|
125
127
|
{ type: 'string' },
|
|
126
128
|
{ type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
127
129
|
],
|
|
128
|
-
description: 'Verified file/dir
|
|
130
|
+
description: 'Verified file/dir or project root; guessed → find first.',
|
|
129
131
|
},
|
|
130
132
|
glob: {
|
|
131
133
|
anyOf: [
|
|
@@ -134,21 +136,24 @@ export const BUILTIN_TOOLS = [
|
|
|
134
136
|
],
|
|
135
137
|
description: 'Glob filter; no guessed src/** under path ".".',
|
|
136
138
|
},
|
|
137
|
-
output_mode: { type: 'string', enum: ['content_with_context', '
|
|
139
|
+
output_mode: { type: 'string', enum: ['content_with_context', 'files_with_matches', 'count'], description: 'Omitted/contextual content; content_with_context; files_with_matches/count are existence modes. Legacy content is runtime-compatible, not public.' },
|
|
138
140
|
head_limit: { type: 'number', minimum: 0, description: 'Max results.' },
|
|
139
141
|
offset: { type: 'number', minimum: 0, description: 'Skip results for paging.' },
|
|
140
142
|
'-A': { type: 'number', minimum: 0, description: 'Lines after each match.' },
|
|
141
143
|
'-B': { type: 'number', minimum: 0, description: 'Lines before each match.' },
|
|
142
144
|
'-C': { type: 'number', minimum: 0, description: 'Lines before/after each match.' },
|
|
143
145
|
},
|
|
144
|
-
|
|
146
|
+
anyOf: [
|
|
147
|
+
{ required: ['pattern'] },
|
|
148
|
+
{ required: ['glob'] },
|
|
149
|
+
],
|
|
145
150
|
},
|
|
146
151
|
},
|
|
147
152
|
{
|
|
148
153
|
name: 'glob',
|
|
149
154
|
title: 'Mixdog Glob',
|
|
150
155
|
annotations: { title: 'Mixdog Glob', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
151
|
-
description: 'Exact glob from verified roots (project root is verified)
|
|
156
|
+
description: 'Exact glob from verified roots (project root is verified); guessed root/name → find first. Array-first: batch related pattern[]/path[]. No path "." + guessed src/**.',
|
|
152
157
|
inputSchema: {
|
|
153
158
|
type: 'object',
|
|
154
159
|
properties: {
|
|
@@ -157,14 +162,14 @@ export const BUILTIN_TOOLS = [
|
|
|
157
162
|
{ type: 'string' },
|
|
158
163
|
{ type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
159
164
|
],
|
|
160
|
-
description: 'Exact glob pattern(s)
|
|
165
|
+
description: 'Exact glob pattern(s); batch related pattern[].',
|
|
161
166
|
},
|
|
162
167
|
path: {
|
|
163
168
|
anyOf: [
|
|
164
169
|
{ type: 'string' },
|
|
165
170
|
{ type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
166
171
|
],
|
|
167
|
-
description: 'Verified base dir(s)
|
|
172
|
+
description: 'Verified base dir(s)/project root; guessed → find first; batch path[].',
|
|
168
173
|
},
|
|
169
174
|
head_limit: { type: 'number', description: 'Max entries.' },
|
|
170
175
|
offset: { type: 'number', description: 'Skip entries.' },
|
|
@@ -176,7 +181,7 @@ export const BUILTIN_TOOLS = [
|
|
|
176
181
|
name: 'find',
|
|
177
182
|
title: 'Mixdog Find Files',
|
|
178
183
|
annotations: { title: 'Mixdog Find Files', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
179
|
-
description: 'Partial path/name lookup
|
|
184
|
+
description: 'Partial path/name lookup only for unknown partial paths/names (including dot dirs); verify roots before grep/glob. Not for the project root or already-verified roots. Output paths are verified downstream. Array-first: batch query[].',
|
|
180
185
|
inputSchema: {
|
|
181
186
|
type: 'object',
|
|
182
187
|
properties: {
|
|
@@ -185,7 +190,7 @@ export const BUILTIN_TOOLS = [
|
|
|
185
190
|
{ type: 'string' },
|
|
186
191
|
{ type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
187
192
|
],
|
|
188
|
-
description: 'Partial path/name words
|
|
193
|
+
description: 'Partial path/name words, not contents; query[] batches lookups.',
|
|
189
194
|
},
|
|
190
195
|
path: { type: 'string', description: 'Base directory.' },
|
|
191
196
|
head_limit: { type: 'number', description: 'Max paths.' },
|
|
@@ -197,7 +202,7 @@ export const BUILTIN_TOOLS = [
|
|
|
197
202
|
name: 'list',
|
|
198
203
|
title: 'Mixdog List Directory',
|
|
199
204
|
annotations: { title: 'Mixdog List Directory', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
200
|
-
description: 'List verified directories (project root included)
|
|
205
|
+
description: 'List verified directories (project root included); Guessed dir → find first. Array-first: batch independent dirs as path[].',
|
|
201
206
|
inputSchema: {
|
|
202
207
|
type: 'object',
|
|
203
208
|
properties: {
|
|
@@ -206,7 +211,7 @@ export const BUILTIN_TOOLS = [
|
|
|
206
211
|
{ type: 'string' },
|
|
207
212
|
{ type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
208
213
|
],
|
|
209
|
-
description: 'Verified directory
|
|
214
|
+
description: 'Verified directory; path[] batches directories.',
|
|
210
215
|
},
|
|
211
216
|
head_limit: { type: 'number', description: 'Max entries.' },
|
|
212
217
|
offset: { type: 'number', description: 'Skip N entries for paging.' },
|
|
@@ -19,10 +19,13 @@ export const FS_REACHABILITY_DEADLINE_MS = 5000;
|
|
|
19
19
|
// error). Reject with EFSUNREACHABLE only when the stat itself exceeds the
|
|
20
20
|
// deadline, which is the dead-mount / hung-FS signature.
|
|
21
21
|
export async function assertPathReachable(path, deadlineMs = FS_REACHABILITY_DEADLINE_MS) {
|
|
22
|
-
if (typeof path !== 'string' || path.length === 0) return;
|
|
22
|
+
if (typeof path !== 'string' || path.length === 0) return null;
|
|
23
23
|
const ms = Number(deadlineMs) > 0 ? Number(deadlineMs) : FS_REACHABILITY_DEADLINE_MS;
|
|
24
24
|
let timer = null;
|
|
25
|
-
|
|
25
|
+
// Resolve to the Stats on success so a caller can seed its stat cache and
|
|
26
|
+
// avoid an immediate redundant synchronous re-stat of the same path; a
|
|
27
|
+
// clean FS rejection (ENOENT/EACCES) resolves to null (still "reachable").
|
|
28
|
+
const probe = stat(path).then((s) => s, () => null);
|
|
26
29
|
const deadline = new Promise((resolve) => {
|
|
27
30
|
timer = setTimeout(() => resolve('TIMEOUT'), ms);
|
|
28
31
|
});
|
|
@@ -37,6 +40,7 @@ export async function assertPathReachable(path, deadlineMs = FS_REACHABILITY_DEA
|
|
|
37
40
|
err.code = 'EFSUNREACHABLE';
|
|
38
41
|
throw err;
|
|
39
42
|
}
|
|
43
|
+
return result; // Stats on success, null on a clean FS rejection
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
// Batch variant: reject if ANY path is unreachable. Runs probes concurrently so
|