atris 3.51.0 → 3.53.0

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.
@@ -5,8 +5,9 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const https = require('https');
7
7
 
8
- const YTNOTES_USAGE = 'usage: ytnotes <youtube-url> [haiku|atris-fast|gemini|grok|codex|cursor]';
8
+ const YTNOTES_USAGE = 'usage: ytnotes <youtube-url> [youtube-url-or-playlist...] [haiku|atris-fast|gemini|grok|codex|cursor]';
9
9
  const YTNOTES_HINT = 'zero credits, local captions + a fast engine';
10
+ const NOTES_PLAYLIST_CAP = 10;
10
11
 
11
12
  const DEFAULT_QUERY = [
12
13
  'Create a timestamped YouTube brief for Atris.',
@@ -24,7 +25,7 @@ const ALLOWED_CAPTION_HOST_SUFFIXES = [
24
25
 
25
26
  function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
26
27
  output('');
27
- output(`Usage: ${commandName} notes <youtube-url> [engine]`);
28
+ output(`Usage: ${commandName} notes <youtube-url> [youtube-url-or-playlist...] [engine]`);
28
29
  output(` ${commandName} process <youtube-url> [options]`);
29
30
  output(` ${commandName} digest [--days N]`);
30
31
  output(` ${commandName} watch add <channel-url-or-@handle>`);
@@ -33,7 +34,8 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
33
34
  output(` ${commandName} watch tick`);
34
35
  output(` ${commandName} <youtube-url> [options]`);
35
36
  output('');
36
- output('notes = free local notes, process = 5 credits cloud knowledge');
37
+ output('notes = free local notes for one url, several urls, or a playlist');
38
+ output('process = 5 credits cloud knowledge');
37
39
  output('digest = one decision page from this week\'s video briefs');
38
40
  output('watch = subscribed channels turn into briefs without a human');
39
41
  output('Process a YouTube video through Atris using timestamped transcript-first analysis.');
@@ -52,6 +54,8 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
52
54
  output('');
53
55
  output('Examples:');
54
56
  output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID`);
57
+ output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID https://youtu.be/OTHER_ID`);
58
+ output(` ${commandName} notes https://www.youtube.com/playlist?list=PLAYLIST_ID`);
55
59
  output(` ${commandName} https://www.youtube.com/watch?v=VIDEO_ID`);
56
60
  output(` ${commandName} process https://youtu.be/VIDEO_ID --query "Key takeaways"`);
57
61
  output(` ${commandName} digest`);
@@ -464,6 +468,34 @@ function videoIdFromUrl(url) {
464
468
  return short ? short[1] : null;
465
469
  }
466
470
 
471
+ function looksLikeYoutubeUrl(arg) {
472
+ const text = String(arg || '').trim();
473
+ if (!text || text.startsWith('-')) return false;
474
+ return /youtube\.com|youtu\.be/i.test(text);
475
+ }
476
+
477
+ function isPlaylistUrl(url) {
478
+ const text = String(url || '');
479
+ return /[?&]list=/.test(text) || /\/playlist(?:\?|$|\/)/i.test(text);
480
+ }
481
+
482
+ function parseNotesArgs(argv = []) {
483
+ const urls = [];
484
+ let engine = null;
485
+ let help = false;
486
+ for (const raw of argv) {
487
+ const arg = String(raw);
488
+ if (arg === '--help' || arg === '-h' || arg === 'help') {
489
+ help = true;
490
+ continue;
491
+ }
492
+ if (arg.startsWith('-')) continue;
493
+ if (looksLikeYoutubeUrl(arg)) urls.push(arg);
494
+ else engine = arg;
495
+ }
496
+ return { urls, engine, help };
497
+ }
498
+
467
499
  function dateStamp(now) {
468
500
  if (typeof now === 'string' && /^\d{4}-\d{2}-\d{2}/.test(now)) {
469
501
  return now.slice(0, 10);
@@ -512,6 +544,7 @@ function fileBriefFromNotes({ cwd, url, workDir, now } = {}) {
512
544
  fs.writeFileSync(journalPath, `${existing}${prefix}${line}\n`);
513
545
 
514
546
  console.log(`brief filed: ${relBrief}`);
547
+ return relBrief;
515
548
  } catch {
516
549
  // notes filing must never break the youtube command
517
550
  }
@@ -1015,37 +1048,189 @@ async function watchCommand(args = [], deps = {}) {
1015
1048
  return 2;
1016
1049
  }
1017
1050
 
1018
- function runYoutubeNotes(args = [], deps = {}) {
1019
- const output = deps.output || ((line = '') => console.error(line));
1020
- const url = args[0];
1021
- const engine = args[1];
1022
- if (!url) {
1023
- output(YTNOTES_USAGE);
1024
- output(YTNOTES_HINT);
1025
- return 2;
1051
+ function defaultPlaylistExpander(playlistUrl, deps = {}) {
1052
+ const spawn = deps.spawnSync || spawnSync;
1053
+ const result = spawn('yt-dlp', [
1054
+ '--no-update',
1055
+ '--flat-playlist',
1056
+ '--print',
1057
+ '%(id)s|%(title)s',
1058
+ playlistUrl,
1059
+ ], {
1060
+ encoding: 'utf8',
1061
+ timeout: 60000,
1062
+ maxBuffer: 2 * 1024 * 1024,
1063
+ });
1064
+ if (result.error || (result.status != null && result.status !== 0)) {
1065
+ const detail = String(result.stderr || result.error?.message || 'playlist expand failed').trim();
1066
+ throw new Error(detail || 'playlist expand failed');
1026
1067
  }
1068
+ return parseFlatPlaylist(result.stdout);
1069
+ }
1027
1070
 
1071
+ function defaultNotesItemRunner(url, engine, deps = {}) {
1028
1072
  const script = path.join(__dirname, '..', 'scripts', 'det', 'ytnotes');
1029
1073
  const spawn = deps.spawnSync || spawnSync;
1030
1074
  const childArgs = engine ? [url, engine] : [url];
1031
- const result = spawn(script, childArgs, { stdio: 'inherit' });
1032
- if (result.status == null) return 1;
1033
- if (result.status === 0) {
1034
- fileBriefFromNotes({
1075
+ return spawn(script, childArgs, { stdio: 'inherit' });
1076
+ }
1077
+
1078
+ function readNowMs(deps = {}) {
1079
+ if (typeof deps.nowMs === 'function') return Number(deps.nowMs()) || 0;
1080
+ if (Number.isFinite(deps.nowMs)) return Number(deps.nowMs);
1081
+ return Date.now();
1082
+ }
1083
+
1084
+ function notesItemLabel(item = {}) {
1085
+ return item.id || item.url || '';
1086
+ }
1087
+
1088
+ function expandNotesTargets(urls = [], deps = {}) {
1089
+ const output = deps.output || ((line = '') => console.error(line));
1090
+ const expander = deps.expander || ((playlistUrl) => defaultPlaylistExpander(playlistUrl, deps));
1091
+ const items = [];
1092
+ for (const url of urls) {
1093
+ if (!isPlaylistUrl(url)) {
1094
+ items.push({ url, id: videoIdFromUrl(url) });
1095
+ continue;
1096
+ }
1097
+ let videos = [];
1098
+ try {
1099
+ videos = expander(url, deps);
1100
+ } catch {
1101
+ items.push({ url, id: videoIdFromUrl(url), failed: true });
1102
+ continue;
1103
+ }
1104
+ if (!Array.isArray(videos) || videos.length === 0) {
1105
+ items.push({ url, id: videoIdFromUrl(url), failed: true });
1106
+ continue;
1107
+ }
1108
+ if (videos.length > NOTES_PLAYLIST_CAP) {
1109
+ output(`playlist capped at ${NOTES_PLAYLIST_CAP} videos (${videos.length} found)`);
1110
+ videos = videos.slice(0, NOTES_PLAYLIST_CAP);
1111
+ }
1112
+ for (const video of videos) {
1113
+ if (!video?.id) continue;
1114
+ items.push({
1115
+ url: `https://www.youtube.com/watch?v=${video.id}`,
1116
+ id: video.id,
1117
+ title: video.title,
1118
+ });
1119
+ }
1120
+ }
1121
+ return items;
1122
+ }
1123
+
1124
+ function invokeNotesRunner(url, engine, deps = {}) {
1125
+ const runner = deps.runner;
1126
+ if (runner) return runner(url, engine, deps);
1127
+ return defaultNotesItemRunner(url, engine, deps);
1128
+ }
1129
+
1130
+ function readRunnerStatus(result) {
1131
+ if (typeof result === 'number') return result;
1132
+ if (result && typeof result === 'object') {
1133
+ return result.status == null ? 1 : result.status;
1134
+ }
1135
+ return 1;
1136
+ }
1137
+
1138
+ function fileNotesBrief(url, deps = {}) {
1139
+ const briefFiler = deps.briefFiler || fileBriefFromNotes;
1140
+ try {
1141
+ const filed = briefFiler({
1035
1142
  cwd: deps.cwd || process.cwd(),
1036
1143
  url,
1037
1144
  workDir: deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes'),
1038
1145
  now: deps.now || new Date(),
1039
1146
  });
1147
+ return typeof filed === 'string' && filed ? filed : null;
1148
+ } catch {
1149
+ return null;
1150
+ }
1151
+ }
1152
+
1153
+ function runOneNotesItem(item, engine, deps = {}) {
1154
+ const output = deps.output || ((line = '') => console.error(line));
1155
+ const label = notesItemLabel(item);
1156
+ if (item.failed) {
1157
+ output(`${label} 0s FAILED`);
1158
+ return { url: item.url, id: item.id, seconds: 0, ok: false, brief: null };
1159
+ }
1160
+
1161
+ const started = readNowMs(deps);
1162
+ let status = 1;
1163
+ try {
1164
+ status = readRunnerStatus(invokeNotesRunner(item.url, engine, deps));
1165
+ } catch {
1166
+ status = 1;
1167
+ }
1168
+ const brief = status === 0 ? fileNotesBrief(item.url, deps) : null;
1169
+ const seconds = Math.max(0, Math.round((readNowMs(deps) - started) / 1000));
1170
+ const ok = status === 0;
1171
+ output(`${label} ${seconds}s ${ok ? (brief || 'ok') : 'FAILED'}`);
1172
+ return { url: item.url, id: item.id, seconds, ok, brief };
1173
+ }
1174
+
1175
+ function formatNotesSummary(rows = []) {
1176
+ const lines = ['url or id seconds result'];
1177
+ for (const row of rows) {
1178
+ const result = row.ok ? (row.brief || 'ok') : 'FAILED';
1179
+ lines.push(`${notesItemLabel(row)} ${row.seconds}s ${result}`);
1180
+ }
1181
+ return lines.join('\n');
1182
+ }
1183
+
1184
+ function runYoutubeNotesBatch({ urls, engine } = {}, deps = {}) {
1185
+ const output = deps.output || ((line = '') => console.error(line));
1186
+ const items = expandNotesTargets(urls || [], deps);
1187
+ const rows = [];
1188
+ for (const item of items) {
1189
+ rows.push(runOneNotesItem(item, engine, deps));
1190
+ }
1191
+ if (rows.length) {
1192
+ output('');
1193
+ output(formatNotesSummary(rows));
1194
+ }
1195
+ if (!rows.length) return 2;
1196
+ return rows.some((row) => row.ok) ? 0 : 2;
1197
+ }
1198
+
1199
+ function runSingleYoutubeNotes(url, engine, deps = {}) {
1200
+ let result;
1201
+ try {
1202
+ result = invokeNotesRunner(url, engine, deps);
1203
+ } catch {
1204
+ return 1;
1205
+ }
1206
+ const status = readRunnerStatus(result);
1207
+ if (status === 0) fileNotesBrief(url, deps);
1208
+ return status;
1209
+ }
1210
+
1211
+ function runYoutubeNotes(args = [], deps = {}) {
1212
+ const output = deps.output || ((line = '') => console.error(line));
1213
+ const parsed = parseNotesArgs(args);
1214
+ if (parsed.help) {
1215
+ showYoutubeHelp(output, deps.commandName || 'atris youtube');
1216
+ return 0;
1217
+ }
1218
+ if (!parsed.urls.length) {
1219
+ output(YTNOTES_USAGE);
1220
+ output(YTNOTES_HINT);
1221
+ return 2;
1222
+ }
1223
+ if (parsed.urls.length === 1 && !isPlaylistUrl(parsed.urls[0])) {
1224
+ return runSingleYoutubeNotes(parsed.urls[0], parsed.engine, deps);
1040
1225
  }
1041
- return result.status;
1226
+ return runYoutubeNotesBatch(parsed, deps);
1042
1227
  }
1043
1228
 
1044
1229
  async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
1045
1230
  const output = deps.output || ((line = '') => console.log(line));
1046
1231
  if (argv[0] === 'notes') {
1047
1232
  const code = runYoutubeNotes(argv.slice(1), deps);
1048
- if (!deps.output && !deps.spawnSync) process.exit(code);
1233
+ if (!deps.output && !deps.spawnSync && !deps.runner && !deps.expander) process.exit(code);
1049
1234
  return code;
1050
1235
  }
1051
1236
  if (argv[0] === 'digest') {
@@ -1078,19 +1263,17 @@ module.exports = {
1078
1263
  shouldRetryWithLocalTranscript,
1079
1264
  formatYoutubeResult,
1080
1265
  fileBriefFromNotes,
1266
+ isPlaylistUrl,
1267
+ parseNotesArgs,
1268
+ expandNotesTargets,
1269
+ runYoutubeNotesBatch,
1081
1270
  parseDigestArgs,
1082
1271
  collectVideoBriefs,
1083
1272
  buildDigestPrompt,
1084
- runYoutubeDigest,
1085
1273
  normalizeWatchChannel,
1086
1274
  channelVideosUrl,
1087
1275
  parseFlatPlaylist,
1088
1276
  loadWatchState,
1089
- saveWatchState,
1090
- addWatchChannel,
1091
- listWatchChannels,
1092
- removeWatchChannel,
1093
- tickWatch,
1094
1277
  watchCommand,
1095
1278
  youtubeCommand,
1096
1279
  };
@@ -1099,7 +1099,8 @@ function evaluateAutoAccept(task, options = {}) {
1099
1099
  if ((tierRequiresStrictVerify && !verifyResult.ok)
1100
1100
  || verifyResult.reason === 'verify_failed'
1101
1101
  || verifyResult.reason === 'verify_unrunnable'
1102
- || verifyResult.reason === 'verify_worktree_missing') {
1102
+ || verifyResult.reason === 'verify_worktree_missing'
1103
+ || verifyResult.reason === 'verify_workdir_missing') {
1103
1104
  return { eligible: false, ref, reason: verifyResult.reason, verify, ...verifyResult };
1104
1105
  }
1105
1106
  }
package/lib/engine-ask.js CHANGED
@@ -29,7 +29,7 @@ const MAX_ASK_PROMPT_BYTES = 16 * 1024;
29
29
  const MAX_ASK_TOTAL_PROMPT_BYTES = 64 * 1024;
30
30
  const MAX_ASK_OUTPUT_BYTES = 1024 * 1024;
31
31
  const ASK_STOP_GRACE_MS = 250;
32
- const ASK_MODEL_ENGINES = new Set(['claude', 'fable', 'haiku', 'codex', 'cursor', 'devin', 'grok']);
32
+ const ASK_MODEL_ENGINES = new Set(['claude', 'fable', 'haiku', 'codex', 'cursor', 'devin', 'grok', 'agy']);
33
33
  const READ_ONLY_PREAMBLE = [
34
34
  'This is a read-only request.',
35
35
  'Do not modify files, create worktrees, start background agents, or run commands with side effects.',
@@ -241,8 +241,16 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '') {
241
241
  args: ['--no-memory', '--no-subagents', '--permission-mode', 'plan', '--sandbox', 'read-only', ...(model ? ['--model', model] : []), '-p', request],
242
242
  };
243
243
  }
244
- if (engine === 'droid') {
245
- return { engine, bin: profile.bin, args: ['exec', '--disable-builtin-skills', request] };
244
+ if (engine === 'agy') {
245
+ // agy prompts for tool permission even in plan mode and a headless ask
246
+ // has no one to answer, so it denies itself `ls` and fails every ask
247
+ // (verified live 2026-08-19). The sandbox keeps the run read-only while
248
+ // skip-permissions lets sandboxed reads proceed unattended.
249
+ return {
250
+ engine,
251
+ bin: profile.bin,
252
+ args: ['--mode', 'plan', '--sandbox', '--dangerously-skip-permissions', ...(model ? ['--model', model] : []), '-p', request],
253
+ };
246
254
  }
247
255
  throw new Error(`engine ask has no read-only command for ${engine}`);
248
256
  }
@@ -34,7 +34,8 @@ function engineFailureHealthStatus(result) {
34
34
  return 'credit_out';
35
35
  }
36
36
  if (/not installed|command not found|\benoent\b/.test(signalText)) return 'not_installed';
37
- if (/timeout|model-unavailable/.test(signalText)) return 'not_installed';
37
+ // Timeouts and unavailable models are transient: 'error' keeps the engine
38
+ // routable, while 'not_installed' would drop it from routing until a doctor run.
38
39
  return 'error';
39
40
  }
40
41
 
@@ -48,7 +49,19 @@ const ENGINE_SEED_META = Object.freeze({
48
49
  fable: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor']), models: Object.freeze(['opus 5', 'opus 4.8', 'fable', 'haiku']), duty: 'leader', fallback_order: 50 }),
49
50
  composer: Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator', 'executor']), models: Object.freeze(['composer 2.5']), fallback_order: 60 }),
50
51
  haiku: Object.freeze({ tier: 'fast', roles: Object.freeze(['validator']), models: Object.freeze(['haiku']), fallback_order: 70 }),
51
- droid: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['built-in router']), duty: 'errands', fallback_order: 90 }),
52
+ agy: Object.freeze({
53
+ tier: 'pro',
54
+ roles: Object.freeze(['executor']),
55
+ models: Object.freeze([
56
+ 'gemini-3.7-flash-high',
57
+ 'gemini-3.1-pro-high',
58
+ 'claude-sonnet-4-6',
59
+ 'claude-opus-4-6-thinking',
60
+ 'gpt-oss-120b-medium',
61
+ ]),
62
+ duty: 'errands',
63
+ fallback_order: 90,
64
+ }),
52
65
  });
53
66
 
54
67
  function engineRegistryFile(root = process.cwd()) {
@@ -144,9 +157,9 @@ function normalizeEngineEntry(id, saved = {}) {
144
157
  };
145
158
  }
146
159
 
147
- function seededRegistry(root = process.cwd()) {
160
+ function seededRegistry(root = process.cwd(), preloadedRaw = null) {
148
161
  const file = engineRegistryFile(root);
149
- const raw = readRawRegistry(file);
162
+ const raw = preloadedRaw || readRawRegistry(file);
150
163
  const savedById = new Map();
151
164
  for (const entry of raw.engines || []) {
152
165
  const id = canonicalEngineName(entry && (entry.id || entry.name));
@@ -162,7 +175,11 @@ function seededRegistry(root = process.cwd()) {
162
175
  function writeEngineRegistry(root, registry) {
163
176
  const file = engineRegistryFile(root);
164
177
  fs.mkdirSync(path.dirname(file), { recursive: true });
165
- fs.writeFileSync(file, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
178
+ // Atomic write: a concurrent reader must never see a torn file, because a
179
+ // failed parse falls back to the seed registry and erases operator policy.
180
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
181
+ fs.writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
182
+ fs.renameSync(tmp, file);
166
183
  }
167
184
 
168
185
  function setEngineOverrides(name, overrides = {}, root = process.cwd()) {
@@ -221,9 +238,17 @@ function setEngineOverrides(name, overrides = {}, root = process.cwd()) {
221
238
  return { id, ...nextOverrides };
222
239
  }
223
240
 
241
+ // A read only writes when normalization actually changed the saved engines
242
+ // (first seed, schema drift). Settled registries stay untouched, so read
243
+ // paths cannot stomp a mutation another process just landed. The comparison
244
+ // uses the same raw snapshot the seed was built from: one read, one decision.
224
245
  function readEngineRegistry(root = process.cwd(), options = {}) {
225
- const registry = seededRegistry(root);
226
- if (options.persist !== false) writeEngineRegistry(root, registry);
246
+ const raw = readRawRegistry(engineRegistryFile(root));
247
+ const registry = seededRegistry(root, raw);
248
+ const needsPersist = JSON.stringify(raw.engines || []) !== JSON.stringify(registry.engines);
249
+ if (options.persist !== false && needsPersist) {
250
+ writeEngineRegistry(root, registry);
251
+ }
227
252
  return registry;
228
253
  }
229
254
 
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ // Mission ledger compaction. Every mission save appends a full snapshot to
4
+ // .atris/state/missions.jsonl, so a mission saved N times costs N rows and
5
+ // every reader pays for the whole history (2,417 rows for 302 missions when
6
+ // this shipped). Worse, old snapshots keep referencing old run receipts, so
7
+ // the daily runs-prune can never reclaim them. Compacting to one row per
8
+ // mission keeps exactly what every reader reconstructs anyway.
9
+ //
10
+ // Readers' contract (loadMissionMap in commands/mission.js): the surviving
11
+ // row per id must carry the LATEST state but the FIRST display number ever
12
+ // assigned, and first-appearance order must hold so numbering stays stable.
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { displayNumber } = require('./short-name');
17
+
18
+ const DEFAULT_MIN_ROWS = 64;
19
+ const DEFAULT_RATIO = 4;
20
+
21
+ function parseLines(text) {
22
+ return String(text || '')
23
+ .split(/\r?\n/)
24
+ .map((line) => line.trim())
25
+ .filter(Boolean)
26
+ .map((line) => {
27
+ try {
28
+ return JSON.parse(line);
29
+ } catch {
30
+ return null;
31
+ }
32
+ });
33
+ }
34
+
35
+ // Latest snapshot per id, first-seen order, first assigned display number.
36
+ // Rows without an id are not mission snapshots (cloud mission receipts land
37
+ // here as {cloud: true, ...}); they pass through untouched, in order, after
38
+ // the compacted snapshots so latest-row readers still find them.
39
+ function compactRows(rows) {
40
+ const order = [];
41
+ const latest = new Map();
42
+ const firstNumber = new Map();
43
+ const passthrough = [];
44
+ for (const row of rows) {
45
+ if (!row) continue;
46
+ if (!row.id) {
47
+ passthrough.push(row);
48
+ continue;
49
+ }
50
+ if (!latest.has(row.id)) order.push(row.id);
51
+ if (!firstNumber.has(row.id) && displayNumber(row.n)) firstNumber.set(row.id, displayNumber(row.n));
52
+ latest.set(row.id, row);
53
+ }
54
+ const compacted = order.map((id) => {
55
+ const row = { ...latest.get(id) };
56
+ if (firstNumber.has(id)) row.n = firstNumber.get(id);
57
+ else delete row.n;
58
+ return row;
59
+ });
60
+ return compacted.concat(passthrough);
61
+ }
62
+
63
+ // Compact when history outweighs live records by `ratio`, and never bother
64
+ // below `minRows`. Returns a receipt either way; writes atomically so a
65
+ // concurrent reader sees the old file or the new one, never a torn one.
66
+ function compactMissionLedger(file, options = {}) {
67
+ const minRows = Number.isFinite(options.minRows) ? options.minRows : DEFAULT_MIN_ROWS;
68
+ const ratio = Number.isFinite(options.ratio) ? options.ratio : DEFAULT_RATIO;
69
+ // Cheap stat gate so the every-save hook does not re-read a small healthy
70
+ // ledger: below this size compaction cannot be worth a full read.
71
+ const minBytes = Number.isFinite(options.minBytes) ? options.minBytes : 262144;
72
+ if (options.force !== true) {
73
+ try {
74
+ if (fs.statSync(file).size < minBytes) {
75
+ return { compacted: false, reason: 'below_threshold', rows_before: 0, rows_after: 0 };
76
+ }
77
+ } catch {
78
+ return { compacted: false, reason: 'missing', rows_before: 0, rows_after: 0 };
79
+ }
80
+ }
81
+ let text = '';
82
+ try {
83
+ text = fs.readFileSync(file, 'utf8');
84
+ } catch {
85
+ return { compacted: false, reason: 'missing', rows_before: 0, rows_after: 0 };
86
+ }
87
+ const rows = parseLines(text);
88
+ const valid = rows.filter((row) => row && row.id);
89
+ const uniqueIds = new Set(valid.map((row) => row.id)).size;
90
+ const receipt = {
91
+ rows_before: rows.length,
92
+ unique_missions: uniqueIds,
93
+ bytes_before: Buffer.byteLength(text),
94
+ };
95
+ const force = options.force === true;
96
+ if (!force && (rows.length < minRows || rows.length < uniqueIds * ratio)) {
97
+ return { ...receipt, compacted: false, reason: 'below_threshold', rows_after: rows.length };
98
+ }
99
+ const compacted = compactRows(rows.filter(Boolean));
100
+ const nextText = compacted.length
101
+ ? `${compacted.map((row) => JSON.stringify(row)).join('\n')}\n`
102
+ : '';
103
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
104
+ fs.mkdirSync(path.dirname(file), { recursive: true });
105
+ fs.writeFileSync(tmp, nextText, 'utf8');
106
+ // The rename replaces the whole file, so an append that landed after our
107
+ // read would be silently lost. Re-check right before the swap and stand
108
+ // down if anyone wrote in between; the next save retries compaction.
109
+ try {
110
+ if (fs.statSync(file).size !== receipt.bytes_before) {
111
+ fs.unlinkSync(tmp);
112
+ return { ...receipt, compacted: false, reason: 'concurrent_write', rows_after: rows.length };
113
+ }
114
+ } catch {
115
+ fs.unlinkSync(tmp);
116
+ return { ...receipt, compacted: false, reason: 'missing', rows_after: 0 };
117
+ }
118
+ fs.renameSync(tmp, file);
119
+ return {
120
+ ...receipt,
121
+ compacted: true,
122
+ rows_after: compacted.length,
123
+ bytes_after: Buffer.byteLength(nextText),
124
+ };
125
+ }
126
+
127
+ module.exports = { compactMissionLedger, compactRows };
@@ -121,7 +121,34 @@ function runCliJsonStreaming(cliPath, args, options = {}) {
121
121
  ...(options.env || {}),
122
122
  },
123
123
  stdio: ['ignore', 'pipe', 'pipe'],
124
+ // Own process group so a timeout can kill the whole tree: the tick
125
+ // spawns engine workers through a shell, and killing only the direct
126
+ // child leaves them running (and billing) for hours after the timeout.
127
+ detached: process.platform !== 'win32',
124
128
  });
129
+ const killTree = (signal) => {
130
+ try {
131
+ if (process.platform !== 'win32' && proc.pid) process.kill(-proc.pid, signal);
132
+ else proc.kill(signal);
133
+ } catch {
134
+ try { proc.kill(signal); } catch {}
135
+ }
136
+ };
137
+ // Detached children survive the operator's Ctrl-C (they left our process
138
+ // group), so relay the signal to the whole tree ourselves, then let the
139
+ // default exit happen by re-raising after removing our handler.
140
+ const relaySignal = (signal) => {
141
+ killTree('SIGTERM');
142
+ setTimeout(() => killTree('SIGKILL'), 2000).unref();
143
+ process.removeListener(signal, relaySignal);
144
+ process.kill(process.pid, signal);
145
+ };
146
+ process.once('SIGINT', relaySignal);
147
+ process.once('SIGTERM', relaySignal);
148
+ const detachSignalRelay = () => {
149
+ process.removeListener('SIGINT', relaySignal);
150
+ process.removeListener('SIGTERM', relaySignal);
151
+ };
125
152
  let stdout = '';
126
153
  let stderr = '';
127
154
  let done = false;
@@ -130,6 +157,7 @@ function runCliJsonStreaming(cliPath, args, options = {}) {
130
157
  const finish = (status) => {
131
158
  if (done) return;
132
159
  done = true;
160
+ detachSignalRelay();
133
161
  clearTimeout(timer);
134
162
  if (progressTimer) clearInterval(progressTimer);
135
163
  let payload = null;
@@ -146,8 +174,8 @@ function runCliJsonStreaming(cliPath, args, options = {}) {
146
174
  };
147
175
  const timer = setTimeout(() => {
148
176
  killedByTimeout = true;
149
- try { proc.kill('SIGTERM'); } catch {}
150
- setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 3000).unref();
177
+ killTree('SIGTERM');
178
+ setTimeout(() => killTree('SIGKILL'), 3000).unref();
151
179
  }, options.timeoutMs || 660000);
152
180
  progressTimer = options.onProgress
153
181
  ? setInterval(() => {