mixdog 0.9.38 → 0.9.40
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 +176 -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 +316 -2
- package/scripts/tool-smoke.mjs +21 -13
- package/scripts/tool-tui-presentation-test.mjs +202 -0
- package/scripts/tui-transcript-perf-test.mjs +279 -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 +44 -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/compaction-runner.mjs +15 -0
- 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 +67 -2
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +232 -43
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +27 -13
- 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/buffered-appender.mjs +13 -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/lifecycle-api.mjs +4 -0
- package/src/session-runtime/model-recency.mjs +5 -2
- package/src/session-runtime/runtime-core.mjs +36 -2
- package/src/session-runtime/session-turn-api.mjs +12 -0
- package/src/session-runtime/tool-catalog.mjs +34 -0
- package/src/standalone/agent-tool/notify.mjs +13 -0
- package/src/standalone/agent-tool.mjs +53 -70
- package/src/standalone/explore-tool.mjs +6 -7
- package/src/tui/App.jsx +33 -1
- 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 +10 -10
- package/src/tui/app/use-transcript-window.mjs +38 -56
- 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 +866 -300
- package/src/tui/engine/agent-job-feed.mjs +216 -19
- 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 +14 -2
- package/src/tui/engine/session-flow.mjs +22 -2
- package/src/tui/engine/tool-card-results.mjs +64 -37
- package/src/tui/engine/tool-result-status.mjs +75 -21
- package/src/tui/engine/turn.mjs +172 -77
- package/src/tui/engine.mjs +199 -39
- package/src/tui/index.jsx +2 -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,
|
|
@@ -288,7 +289,10 @@ function _getOrSpawnWorker() {
|
|
|
288
289
|
// (the originating call plus any that coalesced onto it before it was
|
|
289
290
|
// posted). A supersede never lands here as a rejection — only a real
|
|
290
291
|
// worker failure does.
|
|
291
|
-
if (ok) {
|
|
292
|
+
if (ok) {
|
|
293
|
+
clearSessionSaveError(id);
|
|
294
|
+
for (const w of waiters) w.resolve();
|
|
295
|
+
}
|
|
292
296
|
else {
|
|
293
297
|
const e = new Error(`[session-store] worker save failed: ${error}`);
|
|
294
298
|
for (const w of waiters) w.reject(e);
|
|
@@ -440,6 +444,7 @@ function _doSaveSync(payload) {
|
|
|
440
444
|
}
|
|
441
445
|
_renameWithRetrySync(tmp, target);
|
|
442
446
|
_upsertSessionSummary(session);
|
|
447
|
+
clearSessionSaveError(id);
|
|
443
448
|
} catch (err) {
|
|
444
449
|
try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
|
|
445
450
|
throw err;
|
|
@@ -579,6 +584,7 @@ async function _doSave(payload) {
|
|
|
579
584
|
}
|
|
580
585
|
_renameWithRetrySync(tmp, target);
|
|
581
586
|
_upsertSessionSummary(session);
|
|
587
|
+
clearSessionSaveError(id);
|
|
582
588
|
} catch (err) {
|
|
583
589
|
try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
|
|
584
590
|
_savePending.delete(id);
|
|
@@ -600,8 +606,37 @@ export function markSessionClosed(id, reason = 'manual') {
|
|
|
600
606
|
_clearDebounce(id);
|
|
601
607
|
const existing = loadSession(id);
|
|
602
608
|
if (!existing) return null;
|
|
603
|
-
|
|
604
|
-
|
|
609
|
+
// Re-close idempotence: a session that is ALREADY tombstoned keeps its
|
|
610
|
+
// ORIGINAL close time (updatedAt) and generation. The old code refreshed
|
|
611
|
+
// updatedAt=Date.now() on every call, so the 5-min idle sweep re-closing a
|
|
612
|
+
// stale summary row reset the tombstone age each cycle — tombstones never
|
|
613
|
+
// matured past the sweep threshold (immortality loop). Preserving the
|
|
614
|
+
// original close time lets the age accumulate so the tombstone sweep can
|
|
615
|
+
// reclaim it.
|
|
616
|
+
//
|
|
617
|
+
// The alreadyClosed / original-close-time / generation decision MUST come
|
|
618
|
+
// from the ON-DISK JSON, read cache-bypassing — NOT from loadSession(),
|
|
619
|
+
// which can serve a stale in-memory OPEN payload (a pending debounced save
|
|
620
|
+
// or a _liveSessions entry) after a late save. Deciding off that stale open
|
|
621
|
+
// copy would make a re-close of an already-tombstoned session look like a
|
|
622
|
+
// FIRST close and reset updatedAt+generation, resurrecting the exact
|
|
623
|
+
// immortality refresh this guard prevents. The disk file is the
|
|
624
|
+
// authoritative tombstone state.
|
|
625
|
+
let onDisk = null;
|
|
626
|
+
try { onDisk = JSON.parse(readFileSync(sessionPath(id), 'utf-8')); }
|
|
627
|
+
catch { onDisk = null; }
|
|
628
|
+
const alreadyClosed = onDisk
|
|
629
|
+
? (onDisk.closed === true || onDisk.status === 'closed')
|
|
630
|
+
: (existing.closed === true);
|
|
631
|
+
// When the on-disk copy is already closed, base the (idempotent) tombstone
|
|
632
|
+
// rewrite on IT rather than on `existing`, so a stale open in-memory
|
|
633
|
+
// payload can never clobber the persisted tombstone's content/fields.
|
|
634
|
+
const base = (alreadyClosed && onDisk) ? onDisk : existing;
|
|
635
|
+
const closeTime = (alreadyClosed && typeof base.updatedAt === 'number' && base.updatedAt > 0)
|
|
636
|
+
? base.updatedAt
|
|
637
|
+
: Date.now();
|
|
638
|
+
const newGen = (typeof base.generation === 'number' ? base.generation : 0) + (alreadyClosed ? 0 : 1);
|
|
639
|
+
const tombstone = { ...base, closed: true, closedReason: alreadyClosed ? (base.closedReason || reason) : reason, status: 'closed', generation: newGen, updatedAt: closeTime };
|
|
605
640
|
// Bypass the queue + guard — this IS the tombstone write.
|
|
606
641
|
const target = sessionPath(id);
|
|
607
642
|
const tmp = target + '.' + randomBytes(6).toString('hex') + '.tmp';
|
|
@@ -613,6 +648,7 @@ export function markSessionClosed(id, reason = 'manual') {
|
|
|
613
648
|
return null;
|
|
614
649
|
}
|
|
615
650
|
_savePending.delete(id);
|
|
651
|
+
clearSessionSaveError(id);
|
|
616
652
|
_clearLiveSession(id);
|
|
617
653
|
_deleteHeartbeat(id);
|
|
618
654
|
_upsertSessionSummary(tombstone);
|
|
@@ -622,7 +658,10 @@ export function markSessionClosed(id, reason = 'manual') {
|
|
|
622
658
|
// it reflects the session's full lifetime including the close turn.
|
|
623
659
|
try {
|
|
624
660
|
const _dataDir = getPluginData();
|
|
625
|
-
|
|
661
|
+
// Emit the close metric only on the FIRST close — a re-close of an
|
|
662
|
+
// already-tombstoned session is a no-op idempotent write and must not
|
|
663
|
+
// spam the close log or double-count lifetimes.
|
|
664
|
+
if (_dataDir && !alreadyClosed) {
|
|
626
665
|
const _ts = new Date().toISOString();
|
|
627
666
|
const _lifeMs = (typeof existing.createdAt === 'number' && existing.createdAt > 0)
|
|
628
667
|
? (tombstone.updatedAt - existing.createdAt)
|
|
@@ -669,6 +708,7 @@ export function bumpSessionGeneration(id, reason = 'detach') {
|
|
|
669
708
|
return null;
|
|
670
709
|
}
|
|
671
710
|
_savePending.delete(id);
|
|
711
|
+
clearSessionSaveError(id);
|
|
672
712
|
_clearLiveSession(id);
|
|
673
713
|
_deleteHeartbeat(id);
|
|
674
714
|
_upsertSessionSummary(detached);
|
|
@@ -715,6 +755,7 @@ export function deleteSession(id, options = {}) {
|
|
|
715
755
|
catch { /* fall through to .hb cleanup */ }
|
|
716
756
|
}
|
|
717
757
|
_deleteHeartbeat(id);
|
|
758
|
+
if (removed || !existsSync(path)) clearSessionSaveError(id);
|
|
718
759
|
// deferSummaryUpdate: bulk callers (tombstone sweep) remove thousands of
|
|
719
760
|
// rows — a per-id _removeSessionSummary would parse+rewrite the multi-MB
|
|
720
761
|
// summary index once PER DELETION. They batch the index update themselves.
|
|
@@ -722,16 +763,24 @@ export function deleteSession(id, options = {}) {
|
|
|
722
763
|
return removed;
|
|
723
764
|
}
|
|
724
765
|
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
766
|
const AGENT_TERMINAL_STATUSES = new Set(['idle', 'done', 'error']);
|
|
730
767
|
// Hard wall-clock ceiling for sessions stuck in status='running'. The
|
|
731
768
|
// stream-watchdog should abort stalled streams within ~120s, but if it misses
|
|
732
769
|
// one (process crash, watchdog not started, provider never returned), this
|
|
733
770
|
// backstop reclaims the file so the sweep doesn't leak zombies indefinitely.
|
|
734
771
|
const RUNNING_STALL_MS = 10 * 60 * 1000;
|
|
772
|
+
// Retention cap for resumable OPEN (non-tombstone) sessions. Lead/user resume
|
|
773
|
+
// closes sessions with { tombstone:false } — the runtime detaches but the
|
|
774
|
+
// session JSON stays open/resumable and is never lifecycle-closed, so without
|
|
775
|
+
// a cap the sessions/ dir grows without bound (observed 782 open files). The
|
|
776
|
+
// sweep prunes open sessions past EITHER bound: older than 14d, or beyond the
|
|
777
|
+
// newest 300 (oldest first). The cap targets ONLY ephemeral agent/ownerless
|
|
778
|
+
// sessions — explicit USER-owned conversations are never auto-pruned (deleting
|
|
779
|
+
// a user's history, including the current foreground session which is idle
|
|
780
|
+
// during a gated sweep, is unacceptable). A session with a live runtime entry
|
|
781
|
+
// (options.isSessionLive) is additionally protected as defense-in-depth.
|
|
782
|
+
const RESUMABLE_OPEN_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
|
783
|
+
const RESUMABLE_OPEN_MAX_COUNT = 300;
|
|
735
784
|
|
|
736
785
|
export function listStoredSessions() {
|
|
737
786
|
const dir = getStoreDir();
|
|
@@ -800,12 +849,44 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
800
849
|
}
|
|
801
850
|
const maxAge = ttlMs || DEFAULT_SESSION_TTL_MS;
|
|
802
851
|
const sweepIdle = options.sweepIdle !== false;
|
|
852
|
+
let terminalReapConfig = null;
|
|
853
|
+
try { terminalReapConfig = loadConfig({ secrets: false }); } catch { /* built-ins remain available */ }
|
|
803
854
|
const tombstoneMaxAgeMs = Number(options.tombstoneMaxAgeMs);
|
|
804
855
|
const sweepTombstones = Number.isFinite(tombstoneMaxAgeMs) && tombstoneMaxAgeMs > 0;
|
|
856
|
+
// Retention cap for resumable open sessions runs only on the idle sweep
|
|
857
|
+
// (never on a tombstone-only pass). isSessionLive protects the current /
|
|
858
|
+
// actively-running sessions from being pruned by the retention cap.
|
|
859
|
+
const isSessionLive = typeof options.isSessionLive === 'function' ? options.isSessionLive : null;
|
|
860
|
+
const retainOpen = sweepIdle && options.retainOpenSessions !== false;
|
|
861
|
+
const _optAge = Number(options.openMaxAgeMs);
|
|
862
|
+
const _optCount = Number(options.openMaxCount);
|
|
863
|
+
const openMaxAgeMs = Number.isFinite(_optAge) && _optAge > 0 ? _optAge : RESUMABLE_OPEN_MAX_AGE_MS;
|
|
864
|
+
const openMaxCount = Number.isFinite(_optCount) && _optCount >= 0 ? _optCount : RESUMABLE_OPEN_MAX_COUNT;
|
|
805
865
|
const dir = getStoreDir();
|
|
806
866
|
if (!existsSync(dir))
|
|
807
867
|
return { cleaned: 0, remaining: 0, details: [], tombstonesCleaned: 0, tombstoneDetails: [], tombstoneErrors: [] };
|
|
808
|
-
|
|
868
|
+
// Reconcile the index-derived candidate set with a direct directory scan:
|
|
869
|
+
// the summary index is a best-effort sidecar that can lag far behind disk
|
|
870
|
+
// (thousands of on-disk .json files may be absent from a smaller index).
|
|
871
|
+
// Any such orphan closed+mature tombstone would otherwise be unreachable by
|
|
872
|
+
// this sweep and accumulate forever. Union the index rows with every
|
|
873
|
+
// on-disk .json id, deduped by id; synthetic { id } rows are sufficient
|
|
874
|
+
// because the loop below re-reads all lifecycle truth from disk. This stays
|
|
875
|
+
// sweep-local and does NOT change listStoredSessionSummaries for other
|
|
876
|
+
// callers. Steady-state cost is one readdirSync plus cheap per-orphan reads.
|
|
877
|
+
const indexRows = listStoredSessionSummaries();
|
|
878
|
+
const summaries = indexRows;
|
|
879
|
+
try {
|
|
880
|
+
const seen = new Set();
|
|
881
|
+
for (const row of indexRows) { if (row?.id) seen.add(row.id); }
|
|
882
|
+
for (const f of readdirSync(dir)) {
|
|
883
|
+
if (!f.endsWith('.json')) continue;
|
|
884
|
+
const id = f.slice(0, -5);
|
|
885
|
+
if (!id || seen.has(id)) continue;
|
|
886
|
+
seen.add(id);
|
|
887
|
+
summaries.push({ id });
|
|
888
|
+
}
|
|
889
|
+
} catch { /* dir scan failure — fall back to index rows only */ }
|
|
809
890
|
const now = Date.now();
|
|
810
891
|
let cleaned = 0;
|
|
811
892
|
let remaining = 0;
|
|
@@ -813,6 +894,11 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
813
894
|
const details = [];
|
|
814
895
|
const tombstoneDetails = [];
|
|
815
896
|
const tombstoneErrors = [];
|
|
897
|
+
// Retention-cap bookkeeping: collect surviving open (non-tombstone)
|
|
898
|
+
// sessions here, then prune oldest-first after the main loop.
|
|
899
|
+
const openCandidates = [];
|
|
900
|
+
let openPruned = 0;
|
|
901
|
+
const openPrunedDetails = [];
|
|
816
902
|
for (const row of summaries) {
|
|
817
903
|
try {
|
|
818
904
|
if (!row?.id) continue;
|
|
@@ -828,38 +914,116 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
828
914
|
const hbPath = join(dir, `${row.id}.hb`);
|
|
829
915
|
if (existsSync(hbPath)) heartbeatMtime = statSync(hbPath).mtimeMs || 0;
|
|
830
916
|
} catch { /* .hb unavailable — fall back to JSON fields */ }
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
917
|
+
// Truth source: the summary index is a deferred/best-effort sidecar,
|
|
918
|
+
// so a row can still claim status='idle'/open while the session JSON
|
|
919
|
+
// was already tombstoned. Read the real session JSON BEFORE the
|
|
920
|
+
// freshness gate so closed-ness is decided from AUTHORITATIVE on-disk
|
|
921
|
+
// state — otherwise idle-sweep re-closes an already-closed session via
|
|
922
|
+
// markSessionClosed (which, pre-fix, reset the tombstone age every
|
|
923
|
+
// 5-min cycle → immortality loop).
|
|
924
|
+
let raw = null;
|
|
925
|
+
try { raw = readFileSync(jsonPath, 'utf-8'); }
|
|
926
|
+
catch { /* racing unlink / transient read failure */ }
|
|
927
|
+
let actual = null;
|
|
928
|
+
let diskClosed;
|
|
929
|
+
if (raw == null) {
|
|
930
|
+
diskClosed = (row.closed === true || row.status === 'closed');
|
|
931
|
+
} else {
|
|
932
|
+
// Cheap top-level scan avoids allocating the whole messages array
|
|
933
|
+
// just to read the closed flag for the (common) fresh open
|
|
934
|
+
// session the gate will skip; full-parse only when the scan can't
|
|
935
|
+
// resolve the top-level flag.
|
|
936
|
+
const scan = scanTopLevelLifecycle(raw);
|
|
937
|
+
if (scan && typeof scan.closed === 'boolean') {
|
|
938
|
+
diskClosed = scan.closed;
|
|
939
|
+
} else {
|
|
940
|
+
try { actual = JSON.parse(raw); } catch { actual = null; }
|
|
941
|
+
diskClosed = actual
|
|
942
|
+
? (actual.closed === true || actual.status === 'closed')
|
|
943
|
+
: (row.closed === true || row.status === 'closed');
|
|
944
|
+
}
|
|
836
945
|
}
|
|
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++;
|
|
946
|
+
if (diskClosed) {
|
|
947
|
+
// Closed sessions are EXEMPT from the freshness gate: a tombstone
|
|
948
|
+
// whose file/hb mtime keeps getting bumped would otherwise stay
|
|
949
|
+
// perpetually "fresh" and never mature. Maturity is governed ONLY
|
|
950
|
+
// by the ORIGINAL close time (disk updatedAt, not row.updatedAt
|
|
951
|
+
// which a stale row may carry from before the close).
|
|
952
|
+
if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
|
|
953
|
+
const closedAt = Number(actual?.updatedAt ?? row.updatedAt);
|
|
954
|
+
const age = now - closedAt;
|
|
955
|
+
if (sweepTombstones && Number.isFinite(closedAt) && age >= tombstoneMaxAgeMs) {
|
|
956
|
+
try {
|
|
957
|
+
if (deleteSession(row.id, { deferSummaryUpdate: true })) {
|
|
958
|
+
tombstonesCleaned++;
|
|
959
|
+
tombstoneDetails.push({ id: row.id, ageSeconds: Math.floor(age / 1000) });
|
|
853
960
|
continue;
|
|
854
961
|
}
|
|
962
|
+
} catch (err) {
|
|
963
|
+
tombstoneErrors.push({ id: row.id, message: err?.message || String(err) });
|
|
964
|
+
remaining++;
|
|
965
|
+
continue;
|
|
855
966
|
}
|
|
856
967
|
}
|
|
968
|
+
// Repair a stale summary row that still claimed the session was
|
|
969
|
+
// open: reflect the real closed state so the next sweep sees the
|
|
970
|
+
// correct closed=true/updatedAt and never re-closes it.
|
|
971
|
+
if (actual && !(row.closed === true || row.status === 'closed')) {
|
|
972
|
+
try { _upsertSessionSummary(actual); } catch { /* best-effort */ }
|
|
973
|
+
}
|
|
857
974
|
remaining++;
|
|
858
975
|
continue;
|
|
859
976
|
}
|
|
977
|
+
// Parse the open record before its freshness gate: completed agents
|
|
978
|
+
// use their provider's Advanced terminal duration rather than the
|
|
979
|
+
// general sweep cadence. A short provider override must therefore
|
|
980
|
+
// not be hidden behind the default 5-minute gate.
|
|
981
|
+
if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
|
|
982
|
+
const gateOwner = (actual && typeof actual.owner === 'string' && actual.owner.length > 0)
|
|
983
|
+
? actual.owner : row.owner;
|
|
984
|
+
const gateStatus = (actual && typeof actual.status === 'string') ? actual.status : row.status;
|
|
985
|
+
const gateProvider = (actual && typeof actual.provider === 'string') ? actual.provider : row.provider;
|
|
986
|
+
const isCompletedAgentForGate = isAgentOwner({ owner: gateOwner })
|
|
987
|
+
&& AGENT_TERMINAL_STATUSES.has(gateStatus);
|
|
988
|
+
const terminalReapMsForGate = isCompletedAgentForGate
|
|
989
|
+
? resolveAgentTerminalReapMs(terminalReapConfig, gateProvider)
|
|
990
|
+
: null;
|
|
991
|
+
if (isCompletedAgentForGate && terminalReapMsForGate == null) {
|
|
992
|
+
remaining++;
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
// Freshness gate — OPEN sessions only (closed sessions handled and
|
|
996
|
+
// `continue`d above). Recently-touched open sessions are skipped
|
|
997
|
+
// cheaply here.
|
|
998
|
+
const freshnessGateMs = sweepIdle
|
|
999
|
+
? (terminalReapMsForGate ?? maxAge)
|
|
1000
|
+
: (sweepTombstones ? tombstoneMaxAgeMs : 0);
|
|
1001
|
+
const newestKnown = Math.max(row.updatedAt || 0, row.lastHeartbeatAt || 0, row.createdAt || 0, jsonMtime, heartbeatMtime);
|
|
1002
|
+
if (freshnessGateMs > 0 && newestKnown > 0 && now - newestKnown <= freshnessGateMs) {
|
|
1003
|
+
remaining++;
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
// Prefer the AUTHORITATIVE on-disk JSON over the best-effort (and
|
|
1007
|
+
// possibly stale) summary row for every open/idle liveness and
|
|
1008
|
+
// ownership decision below — a stale row must not close or prune the
|
|
1009
|
+
// wrong session. Full-parse here if the cheap scan skipped it.
|
|
1010
|
+
if (!actual && raw != null) { try { actual = JSON.parse(raw); } catch { actual = null; } }
|
|
1011
|
+
const effOwner = (actual && typeof actual.owner === 'string' && actual.owner.length > 0)
|
|
1012
|
+
? actual.owner : row.owner;
|
|
1013
|
+
const ownerRef = { owner: effOwner };
|
|
1014
|
+
const effStatus = (actual && typeof actual.status === 'string') ? actual.status : row.status;
|
|
1015
|
+
const effUpdatedAt = Number(actual?.updatedAt) > 0 ? Number(actual.updatedAt) : (row.updatedAt || 0);
|
|
1016
|
+
const effLastHb = Number(actual?.lastHeartbeatAt) > 0 ? Number(actual.lastHeartbeatAt) : (row.lastHeartbeatAt || 0);
|
|
1017
|
+
const effCreatedAt = Number(actual?.createdAt) > 0 ? Number(actual.createdAt) : (row.createdAt || 0);
|
|
1018
|
+
const effBashId = (actual && actual.implicitBashSessionId) || row.implicitBashSessionId || null;
|
|
1019
|
+
const effProvider = (actual && typeof actual.provider === 'string') ? actual.provider : row.provider;
|
|
860
1020
|
// Sweep agent-owned and ownerless (legacy) sessions; skip explicit
|
|
861
|
-
// user sessions before touching heartbeat sidecars.
|
|
862
|
-
|
|
1021
|
+
// user sessions before touching heartbeat sidecars. USER-owned
|
|
1022
|
+
// conversations are NEVER added to the retention-cap candidate set —
|
|
1023
|
+
// the cap must not auto-delete user history (nor the current
|
|
1024
|
+
// foreground session, which is idle during a gated sweep). Only the
|
|
1025
|
+
// ephemeral agent/ownerless sessions below feed the cap.
|
|
1026
|
+
if (typeof effOwner === 'string' && effOwner.length > 0 && !isAgentOwner(ownerRef)) {
|
|
863
1027
|
remaining++;
|
|
864
1028
|
continue;
|
|
865
1029
|
}
|
|
@@ -870,18 +1034,19 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
870
1034
|
// Prefer .hb sidecar mtime — updated at tight cadence (≤5s) without
|
|
871
1035
|
// serialising the full JSON, so it reflects true liveness more
|
|
872
1036
|
// accurately than the JSON timestamp fields.
|
|
873
|
-
let lastActive =
|
|
1037
|
+
let lastActive = effLastHb || effUpdatedAt || effCreatedAt || 0;
|
|
874
1038
|
if (heartbeatMtime) lastActive = Math.max(lastActive, heartbeatMtime);
|
|
875
1039
|
// Running sessions are normally reaped by the stream-watchdog
|
|
876
1040
|
// within ~120s. Skip them here unless they've been silent past
|
|
877
1041
|
// RUNNING_STALL_MS, at which point they are treated as zombies.
|
|
878
|
-
if (
|
|
1042
|
+
if (effStatus === 'running' && now - lastActive <= RUNNING_STALL_MS) {
|
|
879
1043
|
remaining++;
|
|
880
1044
|
continue;
|
|
881
1045
|
}
|
|
882
|
-
const isCompletedAgent = isAgentOwner(
|
|
883
|
-
&& AGENT_TERMINAL_STATUSES.has(
|
|
884
|
-
const
|
|
1046
|
+
const isCompletedAgent = isAgentOwner(ownerRef)
|
|
1047
|
+
&& AGENT_TERMINAL_STATUSES.has(effStatus);
|
|
1048
|
+
const terminalReapMs = isCompletedAgent ? terminalReapMsForGate : null;
|
|
1049
|
+
const sessionMaxAge = terminalReapMs ?? maxAge;
|
|
885
1050
|
if (now - lastActive > sessionMaxAge) {
|
|
886
1051
|
try { markSessionClosed(row.id, 'idle-sweep'); }
|
|
887
1052
|
catch (err) {
|
|
@@ -891,16 +1056,40 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
891
1056
|
cleaned++;
|
|
892
1057
|
details.push({
|
|
893
1058
|
id: row.id,
|
|
894
|
-
owner:
|
|
1059
|
+
owner: effOwner || 'unknown',
|
|
895
1060
|
idleMinutes: Math.round((now - lastActive) / 60000),
|
|
896
|
-
bashSessionId:
|
|
1061
|
+
bashSessionId: effBashId,
|
|
897
1062
|
});
|
|
898
1063
|
} else {
|
|
1064
|
+
if (retainOpen) openCandidates.push({ id: row.id, lastActive });
|
|
899
1065
|
remaining++;
|
|
900
1066
|
}
|
|
901
1067
|
}
|
|
902
1068
|
catch { /* skip corrupt */ }
|
|
903
1069
|
}
|
|
1070
|
+
// ── Retention cap: prune resumable open (non-tombstone) sessions ──────────
|
|
1071
|
+
// Newest-first: keep the most recent openMaxCount, prune anything older than
|
|
1072
|
+
// openMaxAgeMs OR beyond the count. Live/current sessions (isSessionLive)
|
|
1073
|
+
// are never pruned but still occupy a kept slot.
|
|
1074
|
+
if (retainOpen && openCandidates.length > 0) {
|
|
1075
|
+
openCandidates.sort((a, b) => (b.lastActive || 0) - (a.lastActive || 0));
|
|
1076
|
+
let kept = 0;
|
|
1077
|
+
for (const c of openCandidates) {
|
|
1078
|
+
if (isSessionLive && isSessionLive(c.id)) { kept++; continue; }
|
|
1079
|
+
const tooOld = openMaxAgeMs > 0 && now - (c.lastActive || 0) > openMaxAgeMs;
|
|
1080
|
+
const overCount = kept >= openMaxCount;
|
|
1081
|
+
if (!tooOld && !overCount) { kept++; continue; }
|
|
1082
|
+
try {
|
|
1083
|
+
if (deleteSession(c.id, { deferSummaryUpdate: true })) {
|
|
1084
|
+
openPruned++;
|
|
1085
|
+
openPrunedDetails.push({ id: c.id, ageSeconds: Math.floor((now - (c.lastActive || 0)) / 1000) });
|
|
1086
|
+
if (remaining > 0) remaining--;
|
|
1087
|
+
} else {
|
|
1088
|
+
kept++;
|
|
1089
|
+
}
|
|
1090
|
+
} catch { kept++; }
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
904
1093
|
// Orphan .hb reap: a heartbeat sidecar whose .json no longer exists is dead
|
|
905
1094
|
// weight once it is also stale (older than maxAge) — the session JSON was
|
|
906
1095
|
// swept/closed but the .hb lingered (a pre-fix orphaned heartbeat). The
|
|
@@ -920,11 +1109,11 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
920
1109
|
// read-modify-write for the whole sweep instead of one per deleted id
|
|
921
1110
|
// (the index is multi-MB at scale; per-id rewrites made large sweeps
|
|
922
1111
|
// quadratic and stalled boot for seconds).
|
|
923
|
-
if (tombstoneDetails.length > 0) {
|
|
1112
|
+
if (tombstoneDetails.length > 0 || openPrunedDetails.length > 0) {
|
|
924
1113
|
try {
|
|
925
|
-
const deletedIds = new Set(tombstoneDetails.map((d) => d.id));
|
|
1114
|
+
const deletedIds = new Set([...tombstoneDetails, ...openPrunedDetails].map((d) => d.id));
|
|
926
1115
|
_pruneSummaryIndexIds(deletedIds);
|
|
927
1116
|
} catch { /* summary index is best-effort */ }
|
|
928
1117
|
}
|
|
929
|
-
return { cleaned, remaining, details, tombstonesCleaned, tombstoneDetails, tombstoneErrors };
|
|
1118
|
+
return { cleaned, remaining, details, tombstonesCleaned, tombstoneDetails, tombstoneErrors, openPruned, openPrunedDetails };
|
|
930
1119
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from 'fs';
|
|
2
|
-
import { readdir, rmdir, unlink, writeFile } from 'fs/promises';
|
|
2
|
+
import { readdir, rmdir, stat, unlink, writeFile } from 'fs/promises';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { getPluginData } from '../config.mjs';
|
|
5
5
|
import { normalizeOutputPath } from '../tools/builtin/path-utils.mjs';
|
|
@@ -11,6 +11,7 @@ const TOOL_RESULT_SHELL_THRESHOLD_CHARS = 30_000;
|
|
|
11
11
|
const TOOL_RESULT_SEARCH_THRESHOLD_CHARS = 50_000;
|
|
12
12
|
const TOOL_RESULT_GREP_THRESHOLD_CHARS = 20_000;
|
|
13
13
|
export const TOOL_RESULT_OFFLOAD_PREFIX = '[tool output offloaded:';
|
|
14
|
+
const OFFLOAD_PRUNE_MIN_AGE_MS = 10 * 60 * 1000;
|
|
14
15
|
|
|
15
16
|
// Per-tool persistence limits mirror reference per-tool maxResultSizeChars
|
|
16
17
|
// rather than a single global value: Grep persists at 20k (CC GrepTool), Glob
|
|
@@ -147,6 +148,47 @@ export async function clearOffloadSession(sessionId) {
|
|
|
147
148
|
} catch { /* best-effort */ }
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
// Remove sidecars that no longer occur in the live transcript. A serialized
|
|
152
|
+
// path match is conservative: if messages cannot be serialized, or a path is
|
|
153
|
+
// mentioned anywhere in a message, retain the file rather than risk deleting
|
|
154
|
+
// one that can still be read by the model.
|
|
155
|
+
export async function pruneOffloadSession(sessionId, getMessages) {
|
|
156
|
+
if (!sessionId || typeof getMessages !== 'function') return;
|
|
157
|
+
const dir = join(getPluginData(), 'tool-results', safeSessionSegment(sessionId));
|
|
158
|
+
if (!existsSync(dir)) return;
|
|
159
|
+
let candidates;
|
|
160
|
+
try {
|
|
161
|
+
const entries = await readdir(dir);
|
|
162
|
+
candidates = (await Promise.all(entries
|
|
163
|
+
.filter((name) => name.endsWith('.txt'))
|
|
164
|
+
.map(async (name) => {
|
|
165
|
+
const filePath = join(dir, name);
|
|
166
|
+
try {
|
|
167
|
+
const fileStat = await stat(filePath);
|
|
168
|
+
if (Date.now() - fileStat.mtimeMs < OFFLOAD_PRUNE_MIN_AGE_MS) return null;
|
|
169
|
+
return { name, filePath };
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
)).filter(Boolean);
|
|
175
|
+
} catch { /* best-effort */ }
|
|
176
|
+
if (!candidates) return;
|
|
177
|
+
let serialized;
|
|
178
|
+
try { serialized = JSON.stringify(getMessages()); } catch { return; }
|
|
179
|
+
const haystack = process.platform === 'win32' ? serialized.toLowerCase() : serialized;
|
|
180
|
+
await Promise.all(candidates
|
|
181
|
+
.filter(({ name, filePath }) => {
|
|
182
|
+
const normalizedPath = normalizeOutputPath(filePath);
|
|
183
|
+
const needles = [normalizedPath, name];
|
|
184
|
+
return !needles.some((needle) => {
|
|
185
|
+
const value = process.platform === 'win32' ? needle.toLowerCase() : needle;
|
|
186
|
+
return haystack.includes(value);
|
|
187
|
+
});
|
|
188
|
+
})
|
|
189
|
+
.map(({ filePath }) => unlink(filePath).catch(() => { /* best-effort */ })));
|
|
190
|
+
}
|
|
191
|
+
|
|
150
192
|
export function isOffloadedToolResultText(text) {
|
|
151
193
|
return typeof text === 'string' && text.startsWith(TOOL_RESULT_OFFLOAD_PREFIX);
|
|
152
194
|
}
|
|
@@ -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;
|
|
@@ -124,23 +124,31 @@ export function _isBenignSearchExitOne(command, exitCode, signal, stderr) {
|
|
|
124
124
|
function _combineAbortSignals(sessionSignal, externalSignal) {
|
|
125
125
|
const a = sessionSignal || null;
|
|
126
126
|
const b = externalSignal || null;
|
|
127
|
-
if (!a && !b) return null;
|
|
128
|
-
if (!a) return b;
|
|
129
|
-
if (!b) return a;
|
|
130
|
-
if (a === b) return a;
|
|
127
|
+
if (!a && !b) return { signal: null, cleanup() {} };
|
|
128
|
+
if (!a) return { signal: b, cleanup() {} };
|
|
129
|
+
if (!b) return { signal: a, cleanup() {} };
|
|
130
|
+
if (a === b) return { signal: a, cleanup() {} };
|
|
131
131
|
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.any === 'function') {
|
|
132
|
-
try { return AbortSignal.any([a, b]); } catch { /* fall through */ }
|
|
132
|
+
try { return { signal: AbortSignal.any([a, b]), cleanup() {} }; } catch { /* fall through */ }
|
|
133
133
|
}
|
|
134
134
|
const ctl = new AbortController();
|
|
135
135
|
const onAbort = (sig) => {
|
|
136
136
|
if (ctl.signal.aborted) return;
|
|
137
137
|
try { ctl.abort(sig?.reason); } catch { try { ctl.abort(); } catch {} }
|
|
138
138
|
};
|
|
139
|
-
if (a.aborted) { onAbort(a); return ctl.signal; }
|
|
140
|
-
if (b.aborted) { onAbort(b); return ctl.signal; }
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
139
|
+
if (a.aborted) { onAbort(a); return { signal: ctl.signal, cleanup() {} }; }
|
|
140
|
+
if (b.aborted) { onAbort(b); return { signal: ctl.signal, cleanup() {} }; }
|
|
141
|
+
const onAbortA = () => onAbort(a);
|
|
142
|
+
const onAbortB = () => onAbort(b);
|
|
143
|
+
try { a.addEventListener('abort', onAbortA, { once: true }); } catch {}
|
|
144
|
+
try { b.addEventListener('abort', onAbortB, { once: true }); } catch {}
|
|
145
|
+
return {
|
|
146
|
+
signal: ctl.signal,
|
|
147
|
+
cleanup() {
|
|
148
|
+
try { a.removeEventListener('abort', onAbortA); } catch {}
|
|
149
|
+
try { b.removeEventListener('abort', onAbortB); } catch {}
|
|
150
|
+
},
|
|
151
|
+
};
|
|
144
152
|
}
|
|
145
153
|
|
|
146
154
|
function _prefixPowerShellUtf8(command) {
|
|
@@ -228,7 +236,11 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
228
236
|
const userProvidedSession = typeof args.session_id === 'string' && args.session_id.trim().length > 0;
|
|
229
237
|
const shouldCreate = args.create === true || !userProvidedSession;
|
|
230
238
|
effectiveArgs = { ...effectiveArgs, create: shouldCreate };
|
|
231
|
-
|
|
239
|
+
try {
|
|
240
|
+
return await executeBashSessionTool('bash_session', effectiveArgs, bashWorkDir, { abortSignal: combinedPersistAbort.signal, sessionId: options?.sessionId });
|
|
241
|
+
} finally {
|
|
242
|
+
combinedPersistAbort.cleanup();
|
|
243
|
+
}
|
|
232
244
|
}
|
|
233
245
|
|
|
234
246
|
let command = args.command;
|
|
@@ -276,6 +288,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
276
288
|
}
|
|
277
289
|
|
|
278
290
|
let shellEffects;
|
|
291
|
+
let combinedBashAbort = null;
|
|
279
292
|
try {
|
|
280
293
|
shellEffects = await analyzeShellCommandEffects(command, bashWorkDir);
|
|
281
294
|
} catch (err) {
|
|
@@ -423,7 +436,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
423
436
|
let bashAbortSignal = null;
|
|
424
437
|
try { bashAbortSignal = (await getAbortSignalForSession(options?.sessionId)) || null; }
|
|
425
438
|
catch { bashAbortSignal = null; }
|
|
426
|
-
|
|
439
|
+
combinedBashAbort = _combineAbortSignals(bashAbortSignal, options?.abortSignal || null);
|
|
427
440
|
// Sync path only: chain a trailing cwd probe so the session's final
|
|
428
441
|
// working directory persists to the next shell call. Async jobs run
|
|
429
442
|
// detached and are intentionally excluded (they never reach here). The
|
|
@@ -456,7 +469,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
456
469
|
env: spawnEnv,
|
|
457
470
|
cwd: bashWorkDir,
|
|
458
471
|
timeoutMs: timeout,
|
|
459
|
-
abortSignal: combinedBashAbort,
|
|
472
|
+
abortSignal: combinedBashAbort.signal,
|
|
460
473
|
autoBackgroundMs,
|
|
461
474
|
// On a foreground timeout, promote the still-running child to a
|
|
462
475
|
// tracked background job (unlimited) instead of killing it.
|
|
@@ -592,6 +605,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
592
605
|
return _prependDestructiveWarning(command, warningBlock ? `${warningBlock}\n${payload}` : payload);
|
|
593
606
|
}
|
|
594
607
|
finally {
|
|
608
|
+
combinedBashAbort?.cleanup?.();
|
|
595
609
|
if (shellEffects.mutationMode === 'paths') {
|
|
596
610
|
invalidateBuiltinResultCache(shellEffects.paths);
|
|
597
611
|
markCodeGraphDirtyPaths(shellEffects.paths);
|