@ngockhoale/ukit 2.4.0 → 2.4.2
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/CHANGELOG.md +79 -0
- package/package.json +1 -1
- package/scripts/index/refresh-index.mjs +10 -5
- package/src/cli/commands/doctor.js +59 -2
- package/src/core/gatewayProbe.js +143 -15
- package/src/core/gatewayResilienceEnv.js +136 -7
- package/src/index/buildIndex.js +74 -24
- package/templates/.claude/hooks/auto-allow-bash.sh +24 -1
- package/templates/.claude/hooks/auto-prune-bash.sh +4 -0
- package/templates/.claude/hooks/block-dangerous.sh +20 -1
- package/templates/.claude/hooks/completion-gate.sh +19 -2
- package/templates/.claude/hooks/compress-output.sh +17 -2
- package/templates/.claude/hooks/context-hardcap-gate.sh +22 -3
- package/templates/.claude/hooks/context-window-guard.sh +84 -61
- package/templates/.claude/hooks/handoff-model-guard.sh +24 -3
- package/templates/.claude/hooks/handoff-resume.sh +21 -3
- package/templates/.claude/hooks/post-edit-verify.sh +19 -2
- package/templates/.claude/hooks/pre-edit-backup.sh +19 -2
- package/templates/.claude/hooks/protect-files.sh +20 -1
- package/templates/.claude/hooks/record-execution.sh +20 -2
- package/templates/.claude/hooks/reinject-context.sh +1 -1
- package/templates/.claude/hooks/reset-compact-pressure.sh +4 -0
- package/templates/.claude/hooks/sensitive-data-guard.sh +64 -17
- package/templates/.claude/hooks/skill-router.sh +44 -5
- package/templates/.claude/hooks/stale-spec-guard.sh +21 -2
- package/templates/.claude/hooks/task-watchdog.sh +25 -7
- package/templates/.claude/hooks/verification-guard.sh +54 -19
- package/templates/.claude/hooks/vision-router.sh +96 -12
- package/templates/.claude/ukit/index/lib/index-core.mjs +78 -16
- package/templates/.claude/ukit/index/post-edit-verify.mjs +8 -0
- package/templates/.claude/ukit/index/pre-edit-backup.mjs +8 -0
- package/templates/.claude/ukit/index/refresh-index.mjs +10 -5
- package/templates/.claude/ukit/index/stale-spec-check.mjs +8 -0
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +8 -0
- package/templates/.claude/ukit/runtime/hook-input.mjs +120 -0
- package/templates/.claude/ukit/runtime/hook-input.sh +60 -0
- package/templates/.claude/ukit/runtime/output-compression.mjs +8 -0
- package/templates/.claude/ukit/runtime/reinject-context.mjs +8 -0
- package/templates/.claude/ukit/runtime/transcript-tail.mjs +107 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import {
|
|
4
4
|
buildCodeIndex,
|
|
5
|
-
|
|
5
|
+
inspectIndexStaleness,
|
|
6
6
|
DEFAULT_INDEX_CACHE_MAX_AGE_MS,
|
|
7
7
|
getIndexArtifactGeneratedAt,
|
|
8
8
|
} from './lib/index-core.mjs';
|
|
@@ -14,9 +14,10 @@ const changedFiles = changedArg ? changedArg.split(',').map((x) => x.trim()).fil
|
|
|
14
14
|
const force = args.includes('--force');
|
|
15
15
|
|
|
16
16
|
const lastRefreshMs = await getIndexArtifactGeneratedAt({ rootDir });
|
|
17
|
-
const
|
|
18
|
-
?
|
|
19
|
-
: await
|
|
17
|
+
const staleness = force
|
|
18
|
+
? null
|
|
19
|
+
: await inspectIndexStaleness({ rootDir, maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS, generatedAtMs: lastRefreshMs });
|
|
20
|
+
const stale = force || staleness.stale;
|
|
20
21
|
|
|
21
22
|
if (!stale) {
|
|
22
23
|
console.log('[ukit:index:refresh] skipped (cache fresh)');
|
|
@@ -28,7 +29,11 @@ if (!stale) {
|
|
|
28
29
|
}
|
|
29
30
|
console.log(`root: ${rootDir}`);
|
|
30
31
|
} else {
|
|
31
|
-
|
|
32
|
+
// Reuse the staleness check's discovery snapshot: one enumeration per refresh.
|
|
33
|
+
const summary = await buildCodeIndex({
|
|
34
|
+
rootDir,
|
|
35
|
+
discoverySnapshot: staleness ? staleness.snapshot : null,
|
|
36
|
+
});
|
|
32
37
|
|
|
33
38
|
console.log('[ukit:index:refresh] completed');
|
|
34
39
|
if (lastRefreshMs !== null) {
|
|
@@ -13,6 +13,14 @@ import {
|
|
|
13
13
|
summarizeSnippet,
|
|
14
14
|
} from '../runtime/safe-patch-core.mjs';
|
|
15
15
|
|
|
16
|
+
// Hook-context self-deadline (2.4.1 orphan-leak class): the stale-spec-guard hook passes
|
|
17
|
+
// UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
|
|
18
|
+
// budget. Non-hook usage never sets it and is never self-killed.
|
|
19
|
+
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
|
|
20
|
+
if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
|
|
21
|
+
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
function getToolName(payload = {}) {
|
|
17
25
|
return String(payload.tool_name || payload.tool || payload.name || '').trim();
|
|
18
26
|
}
|
|
@@ -7,6 +7,14 @@ import path from 'node:path';
|
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { withFileLock } from './token-utils.mjs';
|
|
9
9
|
|
|
10
|
+
// Hook-context self-deadline (2.4.1 orphan-leak class): wrapper hooks pass
|
|
11
|
+
// UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the
|
|
12
|
+
// hook budget. CLI usage never sets it and is never self-killed.
|
|
13
|
+
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
|
|
14
|
+
if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
|
|
15
|
+
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
const LEDGER_VERSION = 1;
|
|
11
19
|
const RESUME_INTENT_VERSION = 1;
|
|
12
20
|
const RESUME_INTENT_TTL_MS = 30 * 60 * 1000;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hook-input.mjs — canonical bounded stdin transport for UKit hooks (H01).
|
|
3
|
+
//
|
|
4
|
+
// readHookInput({ maxBytes, overflowPolicy, stdin }) ->
|
|
5
|
+
// Promise<{ text, truncated, tempPath }>
|
|
6
|
+
//
|
|
7
|
+
// Policies: 'truncate' | 'temp-file' | 'refuse' (throws HookInputOverflow
|
|
8
|
+
// after draining stdin, so producers never see EPIPE).
|
|
9
|
+
//
|
|
10
|
+
// CLI form stages stdin to --out and prints one JSON report line:
|
|
11
|
+
// node hook-input.mjs --max-bytes 2097152 --policy truncate --out /tmp/staged
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { pathToFileURL } from 'node:url';
|
|
17
|
+
import { realpathSync } from 'node:fs';
|
|
18
|
+
|
|
19
|
+
export class HookInputOverflow extends Error {
|
|
20
|
+
constructor(maxBytes) {
|
|
21
|
+
super(`hook stdin exceeds ${maxBytes} bytes`);
|
|
22
|
+
this.name = 'HookInputOverflow';
|
|
23
|
+
this.maxBytes = maxBytes;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function readHookInput({
|
|
28
|
+
maxBytes = 2 * 1024 * 1024,
|
|
29
|
+
overflowPolicy = 'truncate',
|
|
30
|
+
stdin = process.stdin,
|
|
31
|
+
tempRoot = os.tmpdir(),
|
|
32
|
+
} = {}) {
|
|
33
|
+
const chunks = [];
|
|
34
|
+
let total = 0;
|
|
35
|
+
let truncated = false;
|
|
36
|
+
let overflow = false;
|
|
37
|
+
for await (const chunk of stdin) {
|
|
38
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
39
|
+
if (truncated || overflow) continue;
|
|
40
|
+
if (total + buf.length > maxBytes) {
|
|
41
|
+
if (overflowPolicy === 'refuse') {
|
|
42
|
+
overflow = true;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const keep = maxBytes - total;
|
|
46
|
+
if (keep > 0) chunks.push(buf.subarray(0, keep));
|
|
47
|
+
truncated = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
total += buf.length;
|
|
51
|
+
chunks.push(buf);
|
|
52
|
+
}
|
|
53
|
+
if (overflow) throw new HookInputOverflow(maxBytes);
|
|
54
|
+
const body = Buffer.concat(chunks);
|
|
55
|
+
let tempPath = null;
|
|
56
|
+
if (overflowPolicy === 'temp-file') {
|
|
57
|
+
const dir = fs.mkdtempSync(path.join(tempRoot, 'ukit-hook-in.'));
|
|
58
|
+
tempPath = path.join(dir, 'payload');
|
|
59
|
+
fs.writeFileSync(tempPath, body);
|
|
60
|
+
}
|
|
61
|
+
return { text: body.toString('utf8'), truncated, tempPath };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseArgs(argv) {
|
|
65
|
+
const parsed = { maxBytes: 2 * 1024 * 1024, policy: 'truncate', out: null };
|
|
66
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
67
|
+
const arg = argv[i];
|
|
68
|
+
if (arg === '--max-bytes') parsed.maxBytes = Number.parseInt(argv[++i], 10);
|
|
69
|
+
else if (arg === '--policy') parsed.policy = argv[++i];
|
|
70
|
+
else if (arg === '--out') parsed.out = argv[++i];
|
|
71
|
+
}
|
|
72
|
+
if (!['truncate', 'temp-file', 'refuse'].includes(parsed.policy)) {
|
|
73
|
+
throw new Error(`unknown overflow policy: ${parsed.policy}`);
|
|
74
|
+
}
|
|
75
|
+
if (!Number.isFinite(parsed.maxBytes) || parsed.maxBytes <= 0) {
|
|
76
|
+
throw new Error(`invalid --max-bytes: ${parsed.maxBytes}`);
|
|
77
|
+
}
|
|
78
|
+
return parsed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function cli(argv) {
|
|
82
|
+
const { maxBytes, policy, out } = parseArgs(argv);
|
|
83
|
+
let result;
|
|
84
|
+
try {
|
|
85
|
+
result = await readHookInput({ maxBytes, overflowPolicy: policy });
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error instanceof HookInputOverflow) {
|
|
88
|
+
process.stderr.write(`${error.message} (policy: refuse)\n`);
|
|
89
|
+
return 2;
|
|
90
|
+
}
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
if (out) fs.writeFileSync(out, result.text, 'utf8');
|
|
94
|
+
process.stdout.write(
|
|
95
|
+
`${JSON.stringify({
|
|
96
|
+
bytes: Buffer.byteLength(result.text),
|
|
97
|
+
truncated: result.truncated,
|
|
98
|
+
tempPath: result.tempPath,
|
|
99
|
+
})}\n`,
|
|
100
|
+
);
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const isMain = (() => {
|
|
105
|
+
try {
|
|
106
|
+
return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
})();
|
|
111
|
+
|
|
112
|
+
if (isMain) {
|
|
113
|
+
cli(process.argv.slice(2)).then(
|
|
114
|
+
(code) => process.exit(code),
|
|
115
|
+
(error) => {
|
|
116
|
+
process.stderr.write(`hook-input: ${error?.message ?? error}\n`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# hook-input.sh — bounded stdin staging for UKit hook wrappers.
|
|
2
|
+
#
|
|
3
|
+
# H01 fix: wrappers must never materialize unbounded stdin in a shell
|
|
4
|
+
# variable before any deadline is armed. Source this file, stage stdin
|
|
5
|
+
# through ukit_stage_hook_input, then hand UKIT_INPUT_FILE (a path, never
|
|
6
|
+
# the payload) to Node or read a bounded view via command substitution.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh"
|
|
10
|
+
# trap ukit_cleanup_hook_input EXIT
|
|
11
|
+
# ukit_stage_hook_input 2097152 truncate || exit 0
|
|
12
|
+
#
|
|
13
|
+
# Policies:
|
|
14
|
+
# truncate — keep at most max_bytes; UKIT_INPUT_TRUNCATED=1 on overflow
|
|
15
|
+
# temp-file — same bound; the staged file is the payload transport
|
|
16
|
+
# refuse — return 2 without staging when stdin exceeds max_bytes
|
|
17
|
+
#
|
|
18
|
+
# refuse is reserved for fail-closed security hooks (sensitive-data-guard):
|
|
19
|
+
# it returns 2 silently and never echoes payload bytes.
|
|
20
|
+
|
|
21
|
+
ukit_stage_hook_input() {
|
|
22
|
+
local max_bytes="$1" policy="${2:-truncate}"
|
|
23
|
+
local dir file size
|
|
24
|
+
dir="$(mktemp -d "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")" || return 1
|
|
25
|
+
file="$dir/payload"
|
|
26
|
+
head -c $((max_bytes + 1)) > "$file" 2>/dev/null
|
|
27
|
+
# Drain the remainder so the producer never sees EPIPE; discarded bytes
|
|
28
|
+
# are never materialized.
|
|
29
|
+
cat > /dev/null 2>&1 || true
|
|
30
|
+
size="$(wc -c < "$file" | tr -d '[:space:]')"
|
|
31
|
+
UKIT_INPUT_TRUNCATED=0
|
|
32
|
+
if [ "$size" -gt "$max_bytes" ]; then
|
|
33
|
+
case "$policy" in
|
|
34
|
+
refuse)
|
|
35
|
+
rm -rf "$dir"
|
|
36
|
+
UKIT_INPUT_FILE=""
|
|
37
|
+
return 2
|
|
38
|
+
;;
|
|
39
|
+
truncate|temp-file)
|
|
40
|
+
truncate -s "$max_bytes" "$file" 2>/dev/null || { rm -rf "$dir"; return 1; }
|
|
41
|
+
UKIT_INPUT_TRUNCATED=1
|
|
42
|
+
;;
|
|
43
|
+
*)
|
|
44
|
+
rm -rf "$dir"
|
|
45
|
+
return 1
|
|
46
|
+
;;
|
|
47
|
+
esac
|
|
48
|
+
fi
|
|
49
|
+
UKIT_INPUT_FILE="$file"
|
|
50
|
+
return 0
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
ukit_cleanup_hook_input() {
|
|
54
|
+
if [ -n "${UKIT_INPUT_FILE:-}" ] && [ -f "$UKIT_INPUT_FILE" ]; then
|
|
55
|
+
rm -f "$UKIT_INPUT_FILE"
|
|
56
|
+
rmdir "$(dirname "$UKIT_INPUT_FILE")" 2>/dev/null || true
|
|
57
|
+
fi
|
|
58
|
+
UKIT_INPUT_FILE=""
|
|
59
|
+
UKIT_INPUT_TRUNCATED=0
|
|
60
|
+
}
|
|
@@ -16,6 +16,14 @@ import {
|
|
|
16
16
|
} from './token-utils.mjs';
|
|
17
17
|
import { updateCompactPressureFromOutput } from './compact-threshold.mjs';
|
|
18
18
|
|
|
19
|
+
// Hook-context self-deadline (2.4.1 orphan-leak class): the compress-output hook passes
|
|
20
|
+
// UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
|
|
21
|
+
// budget. Non-hook usage never sets it and is never self-killed.
|
|
22
|
+
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
|
|
23
|
+
if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
|
|
24
|
+
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
const ANSI_RE = /\u001b\[[0-9;]*m/g;
|
|
20
28
|
const DEFAULT_MAX_OUTPUT_TOKENS = 180;
|
|
21
29
|
const DEFAULT_OUTPUT_HISTORY_MAX_ENTRIES = 25;
|
|
@@ -19,6 +19,14 @@ import {
|
|
|
19
19
|
writeThresholdCompactPlan,
|
|
20
20
|
} from './compact-threshold.mjs';
|
|
21
21
|
|
|
22
|
+
// Hook-context self-deadline (2.4.1 orphan-leak class): the reinject-context hook passes
|
|
23
|
+
// UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
|
|
24
|
+
// budget. Non-hook usage never sets it and is never self-killed.
|
|
25
|
+
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
|
|
26
|
+
if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
|
|
27
|
+
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
const STATE_TTL_MS = 30 * 60 * 1000;
|
|
23
31
|
const STOPWORDS = new Set([
|
|
24
32
|
'the', 'a', 'an', 'and', 'or', 'to', 'for', 'of', 'with', 'in', 'on', 'is', 'are',
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// transcript-tail.mjs — bounded, reverse-safe JSONL tail reader (H04).
|
|
2
|
+
//
|
|
3
|
+
// scanTranscriptTail(path, { maxBytes, signal }) ->
|
|
4
|
+
// Promise<{ entries, truncated, bytesRead }>
|
|
5
|
+
//
|
|
6
|
+
// Why this exists: the context-window-guard hook used to stat/read/split up to 24 MiB of
|
|
7
|
+
// live transcript synchronously on every UserPromptSubmit. This module is the async,
|
|
8
|
+
// hard-capped replacement: seek near the end of the file, read at most `maxBytes`, skip a
|
|
9
|
+
// partial first line, and parse complete records only.
|
|
10
|
+
//
|
|
11
|
+
// Failure posture is FAIL-OPEN, always: concurrent growth/truncation, vanished files,
|
|
12
|
+
// permission errors, and aborts all RESOLVE (with whatever was read, or an empty result)
|
|
13
|
+
// — scanTranscriptTail never throws and never prints a stack trace. Callers are advisory
|
|
14
|
+
// hooks; a lost measurement must never block a prompt.
|
|
15
|
+
|
|
16
|
+
import fsp from 'node:fs/promises';
|
|
17
|
+
|
|
18
|
+
// Hard ceiling on bytes read per scan. Well under the old 24 MiB synchronous read, and
|
|
19
|
+
// the same order as compact-threshold.mjs's BOUNDARY_TAIL_BYTES so one number describes
|
|
20
|
+
// a UKit tail scan. No config knob exists for byte caps (compact.hardCapTokens is tokens),
|
|
21
|
+
// so this constant IS the ceiling; tests pin it below 24 MiB.
|
|
22
|
+
export const DEFAULT_TAIL_MAX_BYTES = 4 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
const EMPTY_RESULT = () => ({ entries: [], truncated: false, bytesRead: 0 });
|
|
25
|
+
|
|
26
|
+
function positiveInteger(value, fallback) {
|
|
27
|
+
return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value > 0
|
|
28
|
+
? value
|
|
29
|
+
: fallback;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function scanOnce(filePath, maxBytes) {
|
|
33
|
+
const cap = positiveInteger(maxBytes, DEFAULT_TAIL_MAX_BYTES);
|
|
34
|
+
const stat = await fsp.stat(filePath);
|
|
35
|
+
if (!stat.isFile() || stat.size <= 0) {
|
|
36
|
+
return EMPTY_RESULT();
|
|
37
|
+
}
|
|
38
|
+
const start = Math.max(0, stat.size - cap);
|
|
39
|
+
const length = Math.min(cap, stat.size);
|
|
40
|
+
const truncated = start > 0;
|
|
41
|
+
const handle = await fsp.open(filePath, 'r');
|
|
42
|
+
let bytesRead = 0;
|
|
43
|
+
let text = '';
|
|
44
|
+
try {
|
|
45
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
46
|
+
({ bytesRead } = await handle.read(buffer, 0, length, start));
|
|
47
|
+
text = buffer.toString('utf8', 0, bytesRead);
|
|
48
|
+
} finally {
|
|
49
|
+
try {
|
|
50
|
+
await handle.close();
|
|
51
|
+
} catch { /* a racing writer/truncator may have invalidated the handle already */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A tail read almost certainly starts mid-record: drop everything before the first
|
|
55
|
+
// complete line. If the window holds no newline at all, nothing parseable is in it.
|
|
56
|
+
let payload = text;
|
|
57
|
+
if (truncated) {
|
|
58
|
+
const firstNewline = text.indexOf('\n');
|
|
59
|
+
if (firstNewline === -1) {
|
|
60
|
+
return { entries: [], truncated: true, bytesRead };
|
|
61
|
+
}
|
|
62
|
+
payload = text.slice(firstNewline + 1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Parse complete records only; partial or non-JSON lines are skipped, exactly like the
|
|
66
|
+
// consumers that previously split the raw text did. An unterminated final line that
|
|
67
|
+
// still parses (writer flushed the record, newline pending) is a complete record.
|
|
68
|
+
const entries = [];
|
|
69
|
+
for (const line of payload.split('\n')) {
|
|
70
|
+
if (!line) continue;
|
|
71
|
+
try {
|
|
72
|
+
entries.push(JSON.parse(line));
|
|
73
|
+
} catch { /* partial or non-JSON record — skip */ }
|
|
74
|
+
}
|
|
75
|
+
return { entries, truncated, bytesRead };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function scanTranscriptTail(transcriptPath, { maxBytes, signal } = {}) {
|
|
79
|
+
if (typeof transcriptPath !== 'string' || !transcriptPath.trim()) {
|
|
80
|
+
return EMPTY_RESULT();
|
|
81
|
+
}
|
|
82
|
+
if (signal?.aborted) {
|
|
83
|
+
return EMPTY_RESULT();
|
|
84
|
+
}
|
|
85
|
+
// Race the scan against the abort signal so a deadline (e.g. the hook's 3 s
|
|
86
|
+
// self-deadline) abandons an in-flight read instead of waiting on it.
|
|
87
|
+
let onAbort;
|
|
88
|
+
const aborted = new Promise((resolve) => {
|
|
89
|
+
onAbort = () => resolve(EMPTY_RESULT());
|
|
90
|
+
try {
|
|
91
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
92
|
+
} catch {
|
|
93
|
+
resolve(EMPTY_RESULT());
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
try {
|
|
97
|
+
return await Promise.race([scanOnce(transcriptPath, maxBytes), aborted]);
|
|
98
|
+
} catch {
|
|
99
|
+
// Unreadable transcript (first prompt of a session, permissions, races, aborts).
|
|
100
|
+
// Nothing to measure; fail open with an empty result.
|
|
101
|
+
return EMPTY_RESULT();
|
|
102
|
+
} finally {
|
|
103
|
+
try {
|
|
104
|
+
signal?.removeEventListener('abort', onAbort);
|
|
105
|
+
} catch { /* signal went away between add and remove */ }
|
|
106
|
+
}
|
|
107
|
+
}
|