mixdog 0.9.96 → 0.9.97
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 +0 -17
- package/package.json +8 -7
- package/scripts/build-tui.mjs +6 -6
- package/scripts/{channel-daemon-stub.mjs → daemon-stub.mjs} +18 -12
- package/src/defaults/skills/setup/SKILL.md +1 -1
- package/src/lib/keychain-cjs.cjs +1 -1
- package/src/lib/mixdog-debug.cjs +2 -3
- package/src/rules/lead/01-general.md +1 -2
- package/src/rules/lead/lead-tool.md +0 -3
- package/src/rules/shared/01-tool.md +11 -10
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -13
- package/src/runtime/agent/orchestrator/mcp/client.mjs +81 -33
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +3 -6
- package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -6
- package/src/runtime/agent/orchestrator/session/compact.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +22 -4
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +20 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +4 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +40 -25
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -3
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +12 -6
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +38 -2
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +10 -3
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +59 -42
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -2
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +12 -22
- package/src/runtime/agent/orchestrator/tools/builtin/binary-file.mjs +92 -19
- package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +2 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +80 -30
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +41 -22
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +6 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +55 -11
- package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +55 -18
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -0
- package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +1 -2
- package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +67 -21
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +7 -1
- package/src/runtime/channels/lib/config.mjs +33 -33
- package/src/runtime/channels/lib/inbound-handler.mjs +7 -7
- package/src/runtime/channels/lib/interaction-handlers.mjs +6 -6
- package/src/runtime/channels/lib/memory-client.mjs +31 -157
- package/src/runtime/channels/lib/output-forwarder.mjs +16 -16
- package/src/runtime/channels/lib/owned-runtime.mjs +60 -60
- package/src/runtime/channels/lib/owner-heartbeat.mjs +1 -1
- package/src/runtime/channels/lib/{backend-dispatch.mjs → provider-dispatch.mjs} +6 -6
- package/src/runtime/channels/lib/runtime-paths.mjs +4 -30
- package/src/runtime/channels/lib/status-snapshot.mjs +3 -3
- package/src/runtime/channels/lib/tool-dispatch.mjs +4 -4
- package/src/runtime/channels/lib/worker-bootstrap.mjs +1 -1
- package/src/runtime/channels/lib/worker-ipc.mjs +7 -7
- package/src/runtime/channels/lib/worker-main.mjs +38 -38
- package/src/runtime/channels/{backends → providers}/discord-access.mjs +1 -1
- package/src/runtime/channels/{backends → providers}/discord-gateway.mjs +1 -1
- package/src/runtime/channels/{backends → providers}/discord.mjs +3 -3
- package/src/runtime/channels/{backends → providers}/telegram.mjs +12 -12
- package/src/runtime/channels/tool-defs.mjs +1 -1
- package/src/runtime/memory/index.mjs +72 -211
- package/src/runtime/memory/lib/agent-ipc.mjs +9 -8
- package/src/runtime/memory/lib/cycle-llm-adapters.mjs +2 -2
- package/src/runtime/memory/lib/http-router.mjs +1 -6
- package/src/runtime/memory/lib/memory-config-flags.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2-shared.mjs +4 -1
- package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +5 -0
- package/src/runtime/memory/lib/memory-log.mjs +1 -1
- package/src/runtime/memory/lib/memory-process-lock.mjs +5 -60
- package/src/runtime/memory/lib/memory.mjs +1 -1
- package/src/runtime/memory/lib/session-ingest.mjs +1 -1
- package/src/runtime/search/index.mjs +2 -2
- package/src/runtime/shared/abort-race.mjs +70 -0
- package/src/runtime/shared/child-spawn-gate.mjs +101 -63
- package/src/runtime/shared/config.mjs +11 -24
- package/src/runtime/shared/llm/index.mjs +1 -1
- package/src/runtime/shared/owner-fair-gate.mjs +134 -0
- package/src/runtime/shared/resource-admission.mjs +6 -5
- package/src/runtime/shared/tool-execution-owner.mjs +12 -0
- package/src/runtime/shared/tool-result-summary.mjs +1 -1
- package/src/runtime/shared/tool-surface.mjs +91 -22
- package/src/runtime/shared/tool-workload-gates.mjs +29 -0
- package/src/runtime/shared/turn-snapshot.mjs +17 -2
- package/src/session-runtime/channel-config-api.mjs +5 -5
- package/src/session-runtime/config-lifecycle.mjs +27 -27
- package/src/session-runtime/lifecycle-api.mjs +22 -3
- package/src/session-runtime/memory-runtime-prewarm.mjs +15 -0
- package/src/session-runtime/prewarm.mjs +1 -1
- package/src/session-runtime/provider-models.mjs +1 -1
- package/src/session-runtime/remote-control.mjs +6 -7
- package/src/session-runtime/runtime-core.mjs +52 -28
- package/src/session-runtime/self-update.mjs +1 -1
- package/src/session-runtime/session-lifecycle.mjs +36 -15
- package/src/session-runtime/session-title.mjs +1 -1
- package/src/session-runtime/session-turn-api.mjs +55 -15
- package/src/session-runtime/settings-api.mjs +7 -7
- package/src/standalone/agent-dispatch-broker.mjs +1 -1
- package/src/standalone/channel-admin.mjs +42 -53
- package/src/standalone/{channel-daemon-client.mjs → channel-client.mjs} +16 -18
- package/src/standalone/{channel-daemon-transport.mjs → channel-transport.mjs} +45 -46
- package/src/standalone/channel-worker.mjs +124 -647
- package/src/standalone/{backend-daemon.mjs → daemon.mjs} +187 -132
- package/src/standalone/memory-runtime-proxy.mjs +58 -1
- package/src/standalone/{engine-daemon-client.mjs → session-client.mjs} +151 -86
- package/src/standalone/session-protocol.mjs +8 -4
- package/src/standalone/session-runtime-pool.mjs +393 -0
- package/src/standalone/session-runtime-record.mjs +20 -0
- package/src/standalone/session-runtime-worker.mjs +203 -0
- package/src/standalone/{engine-daemon-service.mjs → session-service.mjs} +170 -157
- package/src/standalone/{engine-daemon-transport.mjs → session-transport.mjs} +139 -61
- package/src/standalone/session-wire.mjs +55 -0
- package/src/tui/App.jsx +4 -4
- package/src/tui/app/app-format.mjs +1 -1
- package/src/tui/app/channel-pickers.mjs +41 -41
- package/src/tui/app/input-parsers.mjs +2 -2
- package/src/tui/app/onboarding-steps.mjs +2 -2
- package/src/tui/app/project-picker.mjs +8 -8
- package/src/tui/app/prompt-submit.mjs +8 -8
- package/src/tui/app/settings-picker.mjs +25 -25
- package/src/tui/app/use-prompt-queue-history.mjs +1 -1
- package/src/tui/app/use-transcript-scroll.mjs +1 -1
- package/src/tui/components/ToolExecution.jsx +1 -1
- package/src/tui/components/prompt-input/restore-policy.mjs +1 -1
- package/src/tui/components/tool-execution/surface-detail.mjs +1 -1
- package/src/tui/dist/index.mjs +154 -127
- package/src/tui/hooks/useSession.mjs +16 -0
- package/src/tui/index.jsx +11 -11
- package/src/tui/{engine → session}/agent-envelope.mjs +3 -3
- package/src/tui/{engine → session}/agent-job-feed.mjs +4 -4
- package/src/tui/{engine → session}/boot-profile.mjs +2 -2
- package/src/tui/{engine → session}/context-state.mjs +3 -3
- package/src/tui/{engine → session}/labels.mjs +2 -2
- package/src/tui/{engine → session}/live-share.mjs +7 -7
- package/src/tui/{engine → session}/notice-text.mjs +2 -2
- package/src/tui/{engine → session}/notification-plan.mjs +3 -3
- package/src/tui/session/oauth-flows.mjs +140 -0
- package/src/tui/{engine → session}/prompt-history.mjs +3 -3
- package/src/tui/{engine → session}/queue-helpers.mjs +2 -2
- package/src/tui/{engine → session}/render-timing.mjs +2 -2
- package/src/tui/{engine → session}/session-api-ext.mjs +30 -17
- package/src/tui/{engine → session}/session-api.mjs +7 -60
- package/src/tui/{engine → session}/session-flow.mjs +1 -1
- package/src/tui/{engine → session}/session-stats.mjs +3 -3
- package/src/tui/{engine → session}/tool-approval.mjs +3 -3
- package/src/tui/{engine → session}/tool-call-fields.mjs +2 -2
- package/src/tui/{engine → session}/tool-card-results.mjs +4 -4
- package/src/tui/{engine → session}/tool-result-status.mjs +5 -5
- package/src/tui/{engine → session}/tool-result-text.mjs +3 -3
- package/src/tui/{engine → session}/transcript-spill.mjs +34 -34
- package/src/tui/{engine → session}/turn.mjs +29 -107
- package/src/tui/{engine-local-session.mjs → session-local.mjs} +59 -75
- package/src/tui/session.mjs +22 -0
- package/src/ui/statusline-segments.mjs +1 -1
- package/src/ui/statusline.mjs +1 -1
- package/vendor/ink/build/components/App.js +10 -1
- package/src/session-runtime/memory-daemon-probe.mjs +0 -61
- package/src/standalone/engine-daemon-protocol.mjs +0 -32
- package/src/tui/engine/turn-watchdog.mjs +0 -92
- package/src/tui/engine.mjs +0 -22
- package/src/tui/hooks/useEngine.mjs +0 -16
- /package/src/runtime/agent/orchestrator/session/compact/{engine.mjs → runner.mjs} +0 -0
- /package/src/runtime/channels/{backends → providers}/discord-attachments.mjs +0 -0
- /package/src/tui/{engine → session}/agent-response-tail.mjs +0 -0
- /package/src/tui/{engine → session}/frame-batched-store.mjs +0 -0
- /package/src/tui/{engine → session}/tui-steering-persist.mjs +0 -0
|
@@ -345,6 +345,8 @@ function normalizedRow(row, heartbeatAt = 0) {
|
|
|
345
345
|
preview: cleanText(row.preview),
|
|
346
346
|
generation: typeof row.generation === 'number' ? row.generation : 0,
|
|
347
347
|
implicitBashSessionId: row.implicitBashSessionId || null,
|
|
348
|
+
storageMtimeMs: positiveNumber(row.storageMtimeMs, 0),
|
|
349
|
+
storageSize: positiveNumber(row.storageSize, 0),
|
|
348
350
|
detachedReason: row.detachedReason || null,
|
|
349
351
|
};
|
|
350
352
|
}
|
|
@@ -412,10 +414,15 @@ function sessionHeartbeatMtimes() {
|
|
|
412
414
|
* Returns `null` when the directory itself is NOT enumerable (unreadable
|
|
413
415
|
* stat / readdir): that is not "no sessions", and the caller must keep the
|
|
414
416
|
* authority it already has (the index rows) instead of publishing an empty
|
|
415
|
-
* catalog. `indexRowsById` carries those rows so
|
|
416
|
-
* file
|
|
417
|
+
* catalog. `indexRowsById` carries those rows so unchanged files can reuse the
|
|
418
|
+
* durable summary and a single UNREADABLE file retains its last known row
|
|
419
|
+
* instead of vanishing.
|
|
417
420
|
*/
|
|
418
|
-
function scanSessionFiles(
|
|
421
|
+
function scanSessionFiles(
|
|
422
|
+
heartbeatMtimes = sessionHeartbeatMtimes(),
|
|
423
|
+
indexRowsById = new Map(),
|
|
424
|
+
{ indexMtimeMs = 0, forceRead = false } = {},
|
|
425
|
+
) {
|
|
419
426
|
const directory = join(dataDir(), 'sessions');
|
|
420
427
|
const dirProbe = probePath(directory);
|
|
421
428
|
if (dirProbe.state === PROBE_ABSENT) return [];
|
|
@@ -426,13 +433,37 @@ function scanSessionFiles(heartbeatMtimes = sessionHeartbeatMtimes(), indexRowsB
|
|
|
426
433
|
for (const filename of entries) {
|
|
427
434
|
if (!filename.endsWith('.json')) continue;
|
|
428
435
|
const storageId = filename.slice(0, -5);
|
|
429
|
-
const
|
|
436
|
+
const path = join(directory, filename);
|
|
437
|
+
const retained = indexRowsById.get(storageId);
|
|
438
|
+
const probe = probePath(path);
|
|
439
|
+
// Provably gone: the row legitimately disappears with the file.
|
|
440
|
+
if (probe.state === PROBE_ABSENT) continue;
|
|
441
|
+
if (probe.state !== PROBE_PRESENT) {
|
|
442
|
+
if (retained) rows.push(retained);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (!forceRead && retained) {
|
|
446
|
+
const exactFingerprint = retained.storageMtimeMs > 0
|
|
447
|
+
&& retained.storageSize > 0
|
|
448
|
+
&& retained.storageMtimeMs === probe.mtimeMs
|
|
449
|
+
&& retained.storageSize === probe.size;
|
|
450
|
+
// Older v2 rows predate per-file fingerprints. Their global index
|
|
451
|
+
// timestamp remains a one-release compatibility fallback; every
|
|
452
|
+
// later save/rebuild stamps the exact (mtimeMs,size) pair.
|
|
453
|
+
const legacyUnchanged = retained.storageMtimeMs === 0
|
|
454
|
+
&& indexMtimeMs > 0
|
|
455
|
+
&& (probe.mtimeMs || 0) <= indexMtimeMs;
|
|
456
|
+
if (exactFingerprint || legacyUnchanged) {
|
|
457
|
+
rows.push(retained);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const read = readTextFile(path);
|
|
430
462
|
// Provably gone: the row legitimately disappears with the file.
|
|
431
463
|
if (read.state === PROBE_ABSENT) continue;
|
|
432
464
|
if (read.state !== PROBE_PRESENT) {
|
|
433
465
|
// Exists and could not be read (EACCES/EIO): retain the last known
|
|
434
466
|
// row rather than let a transient IO error delete it from view.
|
|
435
|
-
const retained = indexRowsById.get(storageId);
|
|
436
467
|
if (retained) rows.push(retained);
|
|
437
468
|
continue;
|
|
438
469
|
}
|
|
@@ -440,36 +471,16 @@ function scanSessionFiles(heartbeatMtimes = sessionHeartbeatMtimes(), indexRowsB
|
|
|
440
471
|
// record or a foreign identity is not this file's session.
|
|
441
472
|
const record = readTopLevelLifecycleRecord(read.text);
|
|
442
473
|
if (isLifecycleUnreadable(record) || record.id !== storageId) continue;
|
|
443
|
-
const
|
|
474
|
+
const summary = rowFromSession(record.doc, heartbeatMtimes.get(record.id) || 0);
|
|
475
|
+
const row = summary
|
|
476
|
+
? { ...summary, storageMtimeMs: probe.mtimeMs, storageSize: probe.size }
|
|
477
|
+
: null;
|
|
444
478
|
if (row) rows.push(row);
|
|
445
479
|
}
|
|
446
480
|
return leadRowsWithAgentHeartbeat(rows).sort((left, right) =>
|
|
447
481
|
(right.lastUsedAt || right.updatedAt || 0) - (left.lastUsedAt || left.updatedAt || 0));
|
|
448
482
|
}
|
|
449
483
|
|
|
450
|
-
function sessionFilesAreNewerThan(indexPath) {
|
|
451
|
-
const directory = join(dataDir(), 'sessions');
|
|
452
|
-
if (probePath(directory).state !== PROBE_PRESENT) return false;
|
|
453
|
-
const indexProbe = probePath(indexPath);
|
|
454
|
-
// Only a PROVABLY absent index makes the files newer by definition. An
|
|
455
|
-
// index that exists but cannot be stat'ed (EACCES/EIO) is still the
|
|
456
|
-
// authority we failed to consult — claiming "files are newer" there would
|
|
457
|
-
// replace the whole catalog on the strength of an IO error.
|
|
458
|
-
if (indexProbe.state === PROBE_ABSENT) return true;
|
|
459
|
-
if (indexProbe.state !== PROBE_PRESENT) return false;
|
|
460
|
-
const indexMtime = indexProbe.mtimeMs || 0;
|
|
461
|
-
let entries;
|
|
462
|
-
try { entries = readdirSync(directory); } catch { return false; }
|
|
463
|
-
for (const filename of entries) {
|
|
464
|
-
if (!filename.endsWith('.json')) continue;
|
|
465
|
-
// Absent (raced unlink) and unreadable both prove nothing about
|
|
466
|
-
// freshness: only an observed newer mtime does.
|
|
467
|
-
const probe = probePath(join(directory, filename));
|
|
468
|
-
if (probe.state === PROBE_PRESENT && (probe.mtimeMs || 0) > indexMtime) return true;
|
|
469
|
-
}
|
|
470
|
-
return false;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
484
|
export function listStoredSessionSummaries(options = {}) {
|
|
474
485
|
const heartbeatMtimes = sessionHeartbeatMtimes();
|
|
475
486
|
const indexPath = join(dataDir(), 'session-summaries.json');
|
|
@@ -478,6 +489,7 @@ export function listStoredSessionSummaries(options = {}) {
|
|
|
478
489
|
// an individual session file cannot be read.
|
|
479
490
|
const indexRowsById = new Map();
|
|
480
491
|
let indexRows = null;
|
|
492
|
+
let indexMtimeMs = 0;
|
|
481
493
|
const indexRead = readTextFile(indexPath);
|
|
482
494
|
if (indexRead.state === PROBE_PRESENT) {
|
|
483
495
|
try {
|
|
@@ -490,25 +502,30 @@ export function listStoredSessionSummaries(options = {}) {
|
|
|
490
502
|
indexRows = leadRowsWithAgentHeartbeat(normalizedRows)
|
|
491
503
|
.sort((left, right) =>
|
|
492
504
|
(right.lastUsedAt || right.updatedAt || 0) - (left.lastUsedAt || left.updatedAt || 0));
|
|
505
|
+
const indexProbe = probePath(indexPath);
|
|
506
|
+
if (indexProbe.state === PROBE_PRESENT) indexMtimeMs = indexProbe.mtimeMs || 0;
|
|
493
507
|
}
|
|
494
508
|
} catch { /* malformed sidecar: the files below are the authority */ }
|
|
495
509
|
}
|
|
496
510
|
// An index that EXISTS but is unreadable (EACCES/EIO) is neither missing
|
|
497
511
|
// nor empty: the scan below still runs, and when IT cannot enumerate
|
|
498
512
|
// either, the catalog reports nothing rather than inventing an empty truth.
|
|
499
|
-
const scan = () => scanSessionFiles(
|
|
500
|
-
|
|
513
|
+
const scan = (forceRead = false) => scanSessionFiles(
|
|
514
|
+
heartbeatMtimes,
|
|
515
|
+
indexRowsById,
|
|
516
|
+
{ indexMtimeMs, forceRead },
|
|
517
|
+
);
|
|
518
|
+
if (options.refreshFromStorage === true) return scan(true) ?? indexRows ?? [];
|
|
501
519
|
if (indexRows) {
|
|
502
|
-
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
return scan() ?? indexRows;
|
|
508
|
-
}
|
|
509
|
-
if (indexRows.length > 0 || options.rebuildIfMissing === false) return indexRows;
|
|
520
|
+
if (options.rebuildIfMissing === false) return indexRows;
|
|
521
|
+
// Enumerate identities to detect deletions, stat known files, and parse
|
|
522
|
+
// only rows newer than the index. Cold-start cost therefore scales with
|
|
523
|
+
// changed transcripts rather than total transcript bytes.
|
|
524
|
+
return scan(false) ?? indexRows;
|
|
510
525
|
}
|
|
511
|
-
|
|
526
|
+
// Missing/malformed index is the sole cold path that must rebuild every
|
|
527
|
+
// summary from canonical session bytes.
|
|
528
|
+
return options.rebuildIfMissing === false ? [] : (scan(true) ?? indexRows ?? []);
|
|
512
529
|
}
|
|
513
530
|
|
|
514
531
|
/** Exact, fail-closed existence check for a durable session address.
|
|
@@ -567,7 +584,7 @@ export async function readStoredSessionTranscript(id, options = {}) {
|
|
|
567
584
|
restoreTranscriptItems,
|
|
568
585
|
sessionContextSnapshotProjection,
|
|
569
586
|
} = await import(
|
|
570
|
-
'../../../../tui/
|
|
587
|
+
'../../../../tui/session/session-api-ext.mjs'
|
|
571
588
|
);
|
|
572
589
|
let preparedContextProjection = null;
|
|
573
590
|
try {
|
|
@@ -724,7 +741,7 @@ export async function createStoredSessionLiveViewer(id, options = {}) {
|
|
|
724
741
|
clearStreamingTail: () => commit({ streamingTail: null }),
|
|
725
742
|
};
|
|
726
743
|
const { createLiveShare, liveSharePipePath } = await import(
|
|
727
|
-
'../../../../tui/
|
|
744
|
+
'../../../../tui/session/live-share.mjs'
|
|
728
745
|
);
|
|
729
746
|
const share = createLiveShare({
|
|
730
747
|
ownerSessionId: () => '',
|
|
@@ -257,8 +257,9 @@ export async function processToolBatch(ctx) {
|
|
|
257
257
|
_executeOk = true;
|
|
258
258
|
} else {
|
|
259
259
|
// Fallback for providers that don't stream tool calls early:
|
|
260
|
-
//
|
|
261
|
-
//
|
|
260
|
+
// dispatch the whole remaining batch. The eager dispatcher
|
|
261
|
+
// keeps independent calls parallel while making shell wait
|
|
262
|
+
// for earlier apply_patch calls.
|
|
262
263
|
if (isParallelDispatchable(call.name)) {
|
|
263
264
|
startEagerRun(calls, callIndex, _duplicateCallIds);
|
|
264
265
|
}
|
|
@@ -506,38 +506,24 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
506
506
|
{ backgroundTasksDisabled: _bgTasksDisabled },
|
|
507
507
|
);
|
|
508
508
|
if (longForegroundHint) return formatShellToolFailure(longForegroundHint);
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
// remains a separate, later bound. Never applies to run_in_background
|
|
516
|
-
// (already detached) or persistent sessions (handled far above).
|
|
517
|
-
// Promotion threshold (Codex `yield_time_ms` / CC startBackgrounding
|
|
518
|
-
// analogue). Previously 0 = OFF, so every long command held the turn open
|
|
519
|
-
// to the full foreground timeout and, at the cap, produced a wasted wait.
|
|
520
|
-
// 30 s keeps ordinary builds/tests inline (measured shell p90 ~ 18 s) while
|
|
521
|
-
// detaching the long tail (measured: 111 calls/day above 30 s, 30 of them
|
|
522
|
-
// burning the entire 120 s cap) as a tracked job with partial output.
|
|
509
|
+
// Main-agent blocking budget. A timeout is the command's total deadline,
|
|
510
|
+
// not permission to hold the conversation open for that whole duration:
|
|
511
|
+
// after 15 s a still-running command becomes a tracked background task and
|
|
512
|
+
// completion is pushed to the owner. Explicit timeouts keep their remaining
|
|
513
|
+
// deadline after promotion. Never applies to run_in_background (already
|
|
514
|
+
// detached), persistent sessions, or commands barred from backgrounding.
|
|
523
515
|
// MIXDOG_SHELL_AUTO_BACKGROUND_MS overrides; an explicit 0 disables.
|
|
524
516
|
const _autoBgEnvRaw = process.env.MIXDOG_SHELL_AUTO_BACKGROUND_MS;
|
|
525
517
|
const _autoBgEnvMs = Number(_autoBgEnvRaw);
|
|
526
518
|
const DEFAULT_AUTO_BACKGROUND_MS = (_autoBgEnvRaw != null && String(_autoBgEnvRaw).trim() !== ''
|
|
527
519
|
&& Number.isFinite(_autoBgEnvMs) && _autoBgEnvMs >= 0)
|
|
528
520
|
? Math.floor(_autoBgEnvMs)
|
|
529
|
-
:
|
|
521
|
+
: 15_000;
|
|
530
522
|
// Gate on backgroundOnTimeout rather than just `runInBackground`: it already
|
|
531
523
|
// encodes "promotion is available for this command" (not detached, background
|
|
532
524
|
// tasks enabled, command shape allows it), so the soft threshold can never
|
|
533
525
|
// promote something the hard timeout would refuse to.
|
|
534
|
-
|
|
535
|
-
// (a long build/test/install), so the soft threshold never overrides it —
|
|
536
|
-
// the call stays inline until its own deadline, where backgroundOnTimeout
|
|
537
|
-
// still promotes instead of killing. Auto-promotion therefore only applies
|
|
538
|
-
// to the OMITTED-timeout default, which is exactly the case where nobody
|
|
539
|
-
// declared how long the command should be allowed to hold the turn.
|
|
540
|
-
const autoBackgroundMs = (!backgroundOnTimeout || hasExplicitTimeout || DEFAULT_AUTO_BACKGROUND_MS <= 0)
|
|
526
|
+
const autoBackgroundMs = (!backgroundOnTimeout || DEFAULT_AUTO_BACKGROUND_MS <= 0)
|
|
541
527
|
? 0
|
|
542
528
|
: Math.min(DEFAULT_AUTO_BACKGROUND_MS, timeout);
|
|
543
529
|
|
|
@@ -712,6 +698,10 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
712
698
|
// tracked background job (unlimited) instead of killing it.
|
|
713
699
|
backgroundOnTimeout,
|
|
714
700
|
promotedTimeoutMs,
|
|
701
|
+
// Soft/interrupt promotion happens before the foreground cap.
|
|
702
|
+
// Preserve an explicit total deadline by passing it separately;
|
|
703
|
+
// omitted-timeout promotions retain the existing unlimited job.
|
|
704
|
+
backgroundDeadlineMs: hasExplicitTimeout ? totalTimeout : 0,
|
|
715
705
|
// Threaded so an auto-backgrounded foreground job is stamped with
|
|
716
706
|
// the dispatching terminal's claude.exe pid (per-terminal scope).
|
|
717
707
|
clientHostPid: options?.clientHostPid,
|
|
@@ -1,17 +1,33 @@
|
|
|
1
1
|
import { closeSync, openSync, readSync } from 'fs';
|
|
2
|
+
import { open } from 'fs/promises';
|
|
3
|
+
|
|
4
|
+
const HEAD_CAP = 64 * 1024;
|
|
5
|
+
const TAIL_SIZE = 4 * 1024;
|
|
6
|
+
|
|
7
|
+
function hasUtf16Bom(buf, length = buf?.length || 0) {
|
|
8
|
+
return length >= 2
|
|
9
|
+
&& ((buf[0] === 0xff && buf[1] === 0xfe)
|
|
10
|
+
|| (buf[0] === 0xfe && buf[1] === 0xff));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function containsNull(buf, length = buf?.length || 0) {
|
|
14
|
+
for (let i = 0; i < length; i++) {
|
|
15
|
+
if (buf[i] === 0) return true;
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
2
19
|
|
|
3
20
|
// Binary detection: reading a PNG / ELF / zip / compressed blob as utf-8
|
|
4
21
|
// pollutes the context with U+FFFD characters and wastes tokens. Sample the
|
|
5
22
|
// head and tail of the file and look for a null byte — the canonical signal
|
|
6
23
|
// that the file is not plain text. Head window scales with file size:
|
|
7
24
|
// min(fileSize, 64KB) head + 4KB tail, so a 250KB file with a null byte at
|
|
8
|
-
// 9KB or 249KB is caught equally.
|
|
9
|
-
//
|
|
25
|
+
// 9KB or 249KB is caught equally. This synchronous form remains for legacy
|
|
26
|
+
// mode readers. The main read path uses inspectBinaryFile(), which performs
|
|
27
|
+
// the same probes asynchronously.
|
|
10
28
|
// Callers inside the ≤READ_MAX_SIZE_BYTES branch should pass st.size so the
|
|
11
29
|
// tail probe fires; callers above the cap pass the real size from err.size.
|
|
12
30
|
export function isBinaryFile(fullPath, fileSize = 0) {
|
|
13
|
-
const HEAD_CAP = 64 * 1024; // 64 KB max head window
|
|
14
|
-
const TAIL_SIZE = 4 * 1024; // 4 KB tail probe
|
|
15
31
|
const headBytes = fileSize > 0 ? Math.min(fileSize, HEAD_CAP) : HEAD_CAP;
|
|
16
32
|
let fd = null;
|
|
17
33
|
try {
|
|
@@ -25,20 +41,14 @@ export function isBinaryFile(fullPath, fileSize = 0) {
|
|
|
25
41
|
// (detectReadEncodingFromBuffer/decodeReadBuffer support utf16le and
|
|
26
42
|
// utf16be), so exempt it rather than reject the file as binary.
|
|
27
43
|
// FF FE = UTF-16LE, FE FF = UTF-16BE.
|
|
28
|
-
if (nHead
|
|
29
|
-
|
|
30
|
-
|| (headBuf[0] === 0xfe && headBuf[1] === 0xff))) return false;
|
|
31
|
-
for (let i = 0; i < nHead; i++) {
|
|
32
|
-
if (headBuf[i] === 0) return true;
|
|
33
|
-
}
|
|
44
|
+
if (hasUtf16Bom(headBuf, nHead)) return false;
|
|
45
|
+
if (containsNull(headBuf, nHead)) return true;
|
|
34
46
|
// Tail probe (only when file is larger than head window)
|
|
35
47
|
if (fileSize > headBytes && fileSize > TAIL_SIZE) {
|
|
36
48
|
const tailOffset = fileSize - TAIL_SIZE;
|
|
37
49
|
const tailBuf = Buffer.allocUnsafe(TAIL_SIZE);
|
|
38
50
|
const nTail = readSync(fd, tailBuf, 0, TAIL_SIZE, tailOffset);
|
|
39
|
-
|
|
40
|
-
if (tailBuf[i] === 0) return true;
|
|
41
|
-
}
|
|
51
|
+
if (containsNull(tailBuf, nTail)) return true;
|
|
42
52
|
}
|
|
43
53
|
return false;
|
|
44
54
|
} catch {
|
|
@@ -48,8 +58,71 @@ export function isBinaryFile(fullPath, fileSize = 0) {
|
|
|
48
58
|
}
|
|
49
59
|
}
|
|
50
60
|
|
|
61
|
+
/** Classify an already-loaded whole file without another open/read cycle. */
|
|
62
|
+
export function isBinaryBuffer(buf, fileSize = buf?.length || 0) {
|
|
63
|
+
if (!Buffer.isBuffer(buf) || buf.length === 0) return false;
|
|
64
|
+
const size = fileSize > 0 ? Math.min(fileSize, buf.length) : buf.length;
|
|
65
|
+
const headBytes = Math.min(size, HEAD_CAP);
|
|
66
|
+
if (hasUtf16Bom(buf, headBytes)) return false;
|
|
67
|
+
if (containsNull(buf, headBytes)) return true;
|
|
68
|
+
if (size > headBytes && size > TAIL_SIZE) {
|
|
69
|
+
const tail = buf.subarray(Math.max(0, size - TAIL_SIZE), size);
|
|
70
|
+
if (containsNull(tail)) return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Async binary probe that keeps one file handle for head, tail, and preview.
|
|
77
|
+
* Returns the head bytes even when the null marker is in the tail so callers
|
|
78
|
+
* can render a hex preview without reopening the file.
|
|
79
|
+
*/
|
|
80
|
+
export async function inspectBinaryFile(fullPath, fileSize = 0, { previewBytes = 256 } = {}) {
|
|
81
|
+
const headBytes = fileSize > 0 ? Math.min(fileSize, HEAD_CAP) : HEAD_CAP;
|
|
82
|
+
let fh;
|
|
83
|
+
try {
|
|
84
|
+
fh = await open(fullPath, 'r');
|
|
85
|
+
const headBuf = Buffer.allocUnsafe(Math.max(0, headBytes));
|
|
86
|
+
const { bytesRead: nHead } = await fh.read(headBuf, 0, headBytes, 0);
|
|
87
|
+
const head = headBuf.subarray(0, nHead);
|
|
88
|
+
const preview = head.subarray(0, Math.min(previewBytes, nHead));
|
|
89
|
+
if (nHead === 0 || hasUtf16Bom(head, nHead)) return { isBinary: false, preview };
|
|
90
|
+
if (containsNull(head, nHead)) return { isBinary: true, preview };
|
|
91
|
+
if (fileSize > headBytes && fileSize > TAIL_SIZE) {
|
|
92
|
+
const tailBuf = Buffer.allocUnsafe(TAIL_SIZE);
|
|
93
|
+
const { bytesRead: nTail } = await fh.read(
|
|
94
|
+
tailBuf,
|
|
95
|
+
0,
|
|
96
|
+
TAIL_SIZE,
|
|
97
|
+
fileSize - TAIL_SIZE,
|
|
98
|
+
);
|
|
99
|
+
if (containsNull(tailBuf, nTail)) return { isBinary: true, preview };
|
|
100
|
+
}
|
|
101
|
+
return { isBinary: false, preview };
|
|
102
|
+
} catch {
|
|
103
|
+
return { isBinary: false, preview: Buffer.alloc(0) };
|
|
104
|
+
} finally {
|
|
105
|
+
if (fh) { try { await fh.close(); } catch {} }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
51
109
|
const BINARY_PREVIEW_BYTES = 256;
|
|
52
110
|
|
|
111
|
+
export function formatBinaryReadPreviewFromBuffer(
|
|
112
|
+
buffer,
|
|
113
|
+
displayPath,
|
|
114
|
+
fileSize,
|
|
115
|
+
{ previewBytes = BINARY_PREVIEW_BYTES } = {},
|
|
116
|
+
) {
|
|
117
|
+
const source = Buffer.isBuffer(buffer) ? buffer : Buffer.alloc(0);
|
|
118
|
+
const slice = source.subarray(0, Math.min(previewBytes, source.length));
|
|
119
|
+
const hex = Array.from(slice).map((b) => b.toString(16).padStart(2, '0')).join(' ');
|
|
120
|
+
const disp = displayPath || '(binary file)';
|
|
121
|
+
const note = `binary, ${fileSize} byte${fileSize === 1 ? '' : 's'}`;
|
|
122
|
+
const text = `${disp}\n${note}\n${hex || '(empty)'}`;
|
|
123
|
+
return { text, snapshotMeta: { source: 'read_hex', ranges: [] } };
|
|
124
|
+
}
|
|
125
|
+
|
|
53
126
|
/** Short hex preview for read when null bytes mark the file as binary. */
|
|
54
127
|
export function formatBinaryReadPreview(fullPath, displayPath, fileSize, { previewBytes = BINARY_PREVIEW_BYTES } = {}) {
|
|
55
128
|
const n = Math.max(0, Math.min(previewBytes, fileSize > 0 ? fileSize : previewBytes));
|
|
@@ -58,12 +131,12 @@ export function formatBinaryReadPreview(fullPath, displayPath, fileSize, { previ
|
|
|
58
131
|
fd = openSync(fullPath, 'r');
|
|
59
132
|
const buf = Buffer.alloc(n);
|
|
60
133
|
const bytesRead = readSync(fd, buf, 0, n, 0);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
134
|
+
return formatBinaryReadPreviewFromBuffer(
|
|
135
|
+
buf.subarray(0, bytesRead),
|
|
136
|
+
displayPath || fullPath,
|
|
137
|
+
fileSize,
|
|
138
|
+
{ previewBytes },
|
|
139
|
+
);
|
|
67
140
|
} catch {
|
|
68
141
|
const disp = displayPath || fullPath;
|
|
69
142
|
return {
|
|
@@ -47,6 +47,6 @@ export async function assertPathReachable(path, deadlineMs = FS_REACHABILITY_DEA
|
|
|
47
47
|
// the wall-clock cost is one deadline, not N.
|
|
48
48
|
export async function assertPathsReachable(paths, deadlineMs = FS_REACHABILITY_DEADLINE_MS) {
|
|
49
49
|
const list = Array.isArray(paths) ? paths.filter((p) => typeof p === 'string' && p.length) : [];
|
|
50
|
-
if (list.length === 0) return;
|
|
51
|
-
await Promise.all(list.map((p) => assertPathReachable(p, deadlineMs)));
|
|
50
|
+
if (list.length === 0) return [];
|
|
51
|
+
return await Promise.all(list.map((p) => assertPathReachable(p, deadlineMs)));
|
|
52
52
|
}
|
|
@@ -18,10 +18,12 @@ import {
|
|
|
18
18
|
cacheGet,
|
|
19
19
|
cacheSet,
|
|
20
20
|
getCachedReadOnlyStat,
|
|
21
|
+
lstatPathsForMtime,
|
|
21
22
|
statCacheSet,
|
|
22
23
|
statPathsForMtime,
|
|
23
24
|
registerCacheInvalidationListener,
|
|
24
25
|
} from './cache-layers.mjs';
|
|
26
|
+
import { formatListSize, formatMtime } from './list-formatting.mjs';
|
|
25
27
|
import {
|
|
26
28
|
compileSimpleGlob,
|
|
27
29
|
NOISE_DIR_NAMES,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile, stat } from 'fs/promises';
|
|
2
2
|
import { extname } from 'path';
|
|
3
3
|
import {
|
|
4
4
|
API_IMAGE_MAX_BASE64_SIZE,
|
|
@@ -36,14 +36,14 @@ export function imageMimeForPath(p) {
|
|
|
36
36
|
// sharp absent / resize failed: GRACEFUL fallback to the legacy
|
|
37
37
|
// pass-through-with-cap behaviour (refuse over-cap originals with an isError
|
|
38
38
|
// text result, else emit the raw base64 image block). No throw on missing sharp.
|
|
39
|
-
export async function readImageAsContent(fullPath, displayPath) {
|
|
39
|
+
export async function readImageAsContent(fullPath, displayPath, preflightStat = null) {
|
|
40
40
|
const mimeType = imageMimeForPath(fullPath);
|
|
41
41
|
if (!mimeType) return null;
|
|
42
42
|
let st;
|
|
43
|
-
try { st =
|
|
43
|
+
try { st = preflightStat || await stat(fullPath); } catch { return null; }
|
|
44
44
|
|
|
45
45
|
let buf;
|
|
46
|
-
try { buf =
|
|
46
|
+
try { buf = await readFile(fullPath); } catch { return null; }
|
|
47
47
|
if (buf.length === 0) {
|
|
48
48
|
return {
|
|
49
49
|
content: [{ type: 'text', text: `Error: image "${displayPath}" is empty (0 bytes).` }],
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { lstatSync, realpathSync, readdirSync, statSync } from 'fs';
|
|
2
1
|
import * as fsPromises from 'fs/promises';
|
|
3
2
|
import { readFile } from 'fs/promises';
|
|
4
3
|
import { extname } from 'path';
|
|
@@ -7,7 +6,11 @@ import { buildNotFoundHint, finalizeReadFamilyEnoentTail, tryReadFamilyEnoentRed
|
|
|
7
6
|
import { getReadSnapshot } from './read-snapshot-runtime.mjs';
|
|
8
7
|
import { recordReadPathRedirect } from './snapshot-store.mjs';
|
|
9
8
|
import { snapshotCoversFullFile, statMatchesSnapshot } from './snapshot-helpers.mjs';
|
|
10
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
formatBinaryReadPreviewFromBuffer,
|
|
11
|
+
inspectBinaryFile,
|
|
12
|
+
isBinaryBuffer,
|
|
13
|
+
} from './binary-file.mjs';
|
|
11
14
|
|
|
12
15
|
function snapshotBodyWasReturnedByRead(snapshot) {
|
|
13
16
|
return String(snapshot?.source || '').startsWith('read');
|
|
@@ -27,22 +30,27 @@ function withSymbolReadNote(text, args) {
|
|
|
27
30
|
// decoding. utf8-with-BOM (EF BB BF) keeps the utf-8 decoder; its leading
|
|
28
31
|
// U+FEFF is stripped for display downstream, so bomLen is reported but not
|
|
29
32
|
// applied for utf8.
|
|
33
|
+
function detectReadEncodingFromBuffer(head) {
|
|
34
|
+
const n = head?.length || 0;
|
|
35
|
+
if (n >= 2 && head[0] === 0xff && head[1] === 0xfe) {
|
|
36
|
+
return { encoding: 'utf16le', bomLen: 2 };
|
|
37
|
+
}
|
|
38
|
+
if (n >= 2 && head[0] === 0xfe && head[1] === 0xff) {
|
|
39
|
+
return { encoding: 'utf16be', bomLen: 2 };
|
|
40
|
+
}
|
|
41
|
+
if (n >= 3 && head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf) {
|
|
42
|
+
return { encoding: 'utf8', bomLen: 3 };
|
|
43
|
+
}
|
|
44
|
+
return { encoding: 'utf8', bomLen: 0 };
|
|
45
|
+
}
|
|
46
|
+
|
|
30
47
|
async function detectReadEncoding(fullPath) {
|
|
31
48
|
let fh;
|
|
32
49
|
try {
|
|
33
50
|
fh = await fsPromises.open(fullPath, 'r');
|
|
34
51
|
const head = Buffer.alloc(3);
|
|
35
52
|
const { bytesRead: n } = await fh.read(head, 0, 3, 0);
|
|
36
|
-
|
|
37
|
-
return { encoding: 'utf16le', bomLen: 2 };
|
|
38
|
-
}
|
|
39
|
-
if (n >= 2 && head[0] === 0xfe && head[1] === 0xff) {
|
|
40
|
-
return { encoding: 'utf16be', bomLen: 2 };
|
|
41
|
-
}
|
|
42
|
-
if (n >= 3 && head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf) {
|
|
43
|
-
return { encoding: 'utf8', bomLen: 3 };
|
|
44
|
-
}
|
|
45
|
-
return { encoding: 'utf8', bomLen: 0 };
|
|
53
|
+
return detectReadEncodingFromBuffer(head.subarray(0, n));
|
|
46
54
|
} catch {
|
|
47
55
|
return { encoding: 'utf8', bomLen: 0 };
|
|
48
56
|
} finally {
|
|
@@ -164,7 +172,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
|
|
|
164
172
|
let st;
|
|
165
173
|
let _statErr;
|
|
166
174
|
try {
|
|
167
|
-
st =
|
|
175
|
+
st = options?._preflightStats?.get?.(fullPath) || await fsPromises.stat(fullPath);
|
|
168
176
|
} catch (err) {
|
|
169
177
|
// Fall through to the existing similar-file recovery path below.
|
|
170
178
|
st = null;
|
|
@@ -174,7 +182,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
|
|
|
174
182
|
if (st.isDirectory()) {
|
|
175
183
|
let entries = [];
|
|
176
184
|
try {
|
|
177
|
-
entries =
|
|
185
|
+
entries = (await fsPromises.readdir(fullPath, { withFileTypes: true }))
|
|
178
186
|
.slice(0, 20)
|
|
179
187
|
.map((entry) => `${entry.name}${entry.isDirectory() ? '/' : ''}`);
|
|
180
188
|
} catch { /* best-effort preview */ }
|
|
@@ -191,14 +199,14 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
|
|
|
191
199
|
return `Error: cannot read special file (FIFO / character / block device / socket): ${normalizeOutputPath(filePath)}`;
|
|
192
200
|
// R1+R2: realpath the resolved path so a symlink → /dev/zero (or any
|
|
193
201
|
// other blocked device, UNC, or Windows reserved name) is caught on
|
|
194
|
-
// the REAL target, not the symlink name.
|
|
195
|
-
// the entry IS a symlink first so
|
|
202
|
+
// the REAL target, not the symlink name. lstat detects whether
|
|
203
|
+
// the entry IS a symlink first so realpath is only called when
|
|
196
204
|
// it would actually differ — saves a syscall on the common case.
|
|
197
205
|
try {
|
|
198
|
-
const _lst =
|
|
206
|
+
const _lst = await fsPromises.lstat(fullPath);
|
|
199
207
|
if (_lst && typeof _lst.isSymbolicLink === 'function' && _lst.isSymbolicLink()) {
|
|
200
208
|
let _realTarget = null;
|
|
201
|
-
try { _realTarget =
|
|
209
|
+
try { _realTarget = await fsPromises.realpath(fullPath); } catch { _realTarget = null; }
|
|
202
210
|
if (_realTarget && _realTarget !== fullPath) {
|
|
203
211
|
if (isBlockedDevicePath(_realTarget))
|
|
204
212
|
return `Error: cannot read device file via symlink (would block or produce infinite output): ${normalizeOutputPath(filePath)} → ${normalizeOutputPath(_realTarget)}`;
|
|
@@ -211,7 +219,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
|
|
|
211
219
|
// the target stat could differ from the link stat in
|
|
212
220
|
// pathological cases (replaced under us).
|
|
213
221
|
try {
|
|
214
|
-
const _rst =
|
|
222
|
+
const _rst = await fsPromises.stat(_realTarget);
|
|
215
223
|
if (typeof isSpecialFileStat === 'function' && isSpecialFileStat(_rst))
|
|
216
224
|
return `Error: cannot read special file via symlink (FIFO / character / block device / socket): ${normalizeOutputPath(filePath)} → ${normalizeOutputPath(_realTarget)}`;
|
|
217
225
|
} catch { /* if the target is gone, let the normal path surface ENOENT */ }
|
|
@@ -430,11 +438,42 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
|
|
|
430
438
|
// large UTF-16LE+BOM file is recognized as text up front and routed to
|
|
431
439
|
// the bounded in-memory utf16le path below, never mis-decoded as utf-8
|
|
432
440
|
// or rejected by the utf-8 streaming/binary branch (Bug 1).
|
|
433
|
-
const
|
|
441
|
+
const _preferRangeStream = hasRangeArgs && !wantFull
|
|
442
|
+
&& (options?.forceReadRangeStream === true || st.size > READ_STREAM_RANGE_MIN_BYTES);
|
|
443
|
+
const _preferSmartStream = !hasRangeArgs && !wantFull
|
|
444
|
+
&& st.size >= READ_SMART_STREAM_MIN_BYTES;
|
|
445
|
+
const _preferBufferedRead = st.size <= READ_MAX_SIZE_BYTES
|
|
446
|
+
&& !_preferRangeStream
|
|
447
|
+
&& !_preferSmartStream;
|
|
448
|
+
let _prefetchedRawBuf = null;
|
|
449
|
+
let _prefetchedRawFromCache = false;
|
|
450
|
+
if (_preferBufferedRead) {
|
|
451
|
+
const _cachedRaw = _rawContentCacheGet ? _rawContentCacheGet(fullPath, st) : null;
|
|
452
|
+
if (_cachedRaw) {
|
|
453
|
+
_prefetchedRawBuf = _cachedRaw;
|
|
454
|
+
_prefetchedRawFromCache = true;
|
|
455
|
+
} else {
|
|
456
|
+
try { _prefetchedRawBuf = await readFile(fullPath); } catch { _prefetchedRawBuf = null; }
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const _readEnc = _prefetchedRawBuf
|
|
460
|
+
? detectReadEncodingFromBuffer(_prefetchedRawBuf.subarray(0, Math.min(3, _prefetchedRawBuf.length)))
|
|
461
|
+
: await detectReadEncoding(fullPath);
|
|
434
462
|
// UTF-16 (LE or BE) reads share one constraint: the streaming/binary
|
|
435
463
|
// paths decode chunks as utf-8, so a BOM-flagged UTF-16 file must route
|
|
436
464
|
// to the bounded in-memory decode below regardless of byte order.
|
|
437
465
|
const _isUtf16 = _readEnc.encoding === 'utf16le' || _readEnc.encoding === 'utf16be';
|
|
466
|
+
let _binaryInspection = null;
|
|
467
|
+
const _inspectBinary = async () => {
|
|
468
|
+
if (_binaryInspection) return _binaryInspection;
|
|
469
|
+
_binaryInspection = _prefetchedRawBuf
|
|
470
|
+
? {
|
|
471
|
+
isBinary: isBinaryBuffer(_prefetchedRawBuf, st.size),
|
|
472
|
+
preview: _prefetchedRawBuf.subarray(0, Math.min(256, _prefetchedRawBuf.length)),
|
|
473
|
+
}
|
|
474
|
+
: await inspectBinaryFile(fullPath, st.size);
|
|
475
|
+
return _binaryInspection;
|
|
476
|
+
};
|
|
438
477
|
if (st.size > READ_MAX_SIZE_BYTES) {
|
|
439
478
|
// .pdf/.ipynb were already dispatched up front (MEDIA-WINS), so this
|
|
440
479
|
// >10MiB branch only handles the non-media text path from here on.
|
|
@@ -450,8 +489,13 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
|
|
|
450
489
|
if (!hasRangeArgs) {
|
|
451
490
|
return `Error: file size ${st.size} bytes exceeds ${READ_MAX_SIZE_BYTES}-byte cap.`;
|
|
452
491
|
}
|
|
453
|
-
|
|
454
|
-
|
|
492
|
+
const _binary = await _inspectBinary();
|
|
493
|
+
if (_binary.isBinary) {
|
|
494
|
+
const { text, snapshotMeta } = formatBinaryReadPreviewFromBuffer(
|
|
495
|
+
_binary.preview,
|
|
496
|
+
normalizeOutputPath(filePath),
|
|
497
|
+
st.size,
|
|
498
|
+
);
|
|
455
499
|
_recordReadSnapshot(fullPath, st, readStateScope, snapshotMeta);
|
|
456
500
|
_cacheSet(cacheKey, text, { paths: [fullPath], readSnapshotMeta: snapshotMeta });
|
|
457
501
|
return withSymbolReadNote(text, args);
|
|
@@ -510,11 +554,18 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
|
|
|
510
554
|
// unambiguous TEXT signal, so classify it as utf16le up front and skip
|
|
511
555
|
// NUL rejection. (_readEnc was detected above, before the >10MiB size
|
|
512
556
|
// branch, so the large-file path can classify utf16le.)
|
|
513
|
-
if (!_isUtf16
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
557
|
+
if (!_isUtf16) {
|
|
558
|
+
const _binary = await _inspectBinary();
|
|
559
|
+
if (_binary.isBinary) {
|
|
560
|
+
const { text, snapshotMeta } = formatBinaryReadPreviewFromBuffer(
|
|
561
|
+
_binary.preview,
|
|
562
|
+
normalizeOutputPath(filePath),
|
|
563
|
+
st.size,
|
|
564
|
+
);
|
|
565
|
+
_recordReadSnapshot(fullPath, st, readStateScope, snapshotMeta);
|
|
566
|
+
_cacheSet(cacheKey, text, { paths: [fullPath], readSnapshotMeta: snapshotMeta });
|
|
567
|
+
return withSymbolReadNote(text, args);
|
|
568
|
+
}
|
|
518
569
|
}
|
|
519
570
|
// Whole-file reads above READ_WHOLE_FILE_MAX_BYTES use stream smart-elide
|
|
520
571
|
// (then READ_MAX_OUTPUT_BYTES truncation) instead of refusing. Absolute
|
|
@@ -588,8 +639,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
|
|
|
588
639
|
}
|
|
589
640
|
}
|
|
590
641
|
try {
|
|
591
|
-
const
|
|
592
|
-
const rawBuf = cachedRawBuf || await readFile(fullPath);
|
|
642
|
+
const rawBuf = _prefetchedRawBuf || await readFile(fullPath);
|
|
593
643
|
// Encoding-aware decode (fresh read AND raw-content cache hit both
|
|
594
644
|
// flow through here). For a BOM-flagged UTF-16LE file, strip the
|
|
595
645
|
// 2-byte FF FE BOM and decode as utf16le so it reverses the write
|
|
@@ -614,7 +664,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
|
|
|
614
664
|
// the cache + snapshot record stale bytes.
|
|
615
665
|
let _stPostRead;
|
|
616
666
|
let _readStableForRawCache = true;
|
|
617
|
-
if (
|
|
667
|
+
if (_prefetchedRawFromCache) {
|
|
618
668
|
_stPostRead = st;
|
|
619
669
|
} else {
|
|
620
670
|
try { _stPostRead = await fsPromises.stat(fullPath); } catch { _stPostRead = st; }
|