claudelink 1.4.0 → 1.5.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.
Files changed (49) hide show
  1. package/README.md +34 -1
  2. package/dist/arm-compact.d.ts +2 -0
  3. package/dist/arm-compact.js +182 -0
  4. package/dist/arm-compact.js.map +1 -0
  5. package/dist/cli.js +145 -0
  6. package/dist/cli.js.map +1 -1
  7. package/dist/compact-analyzer.d.ts +67 -0
  8. package/dist/compact-analyzer.js +309 -0
  9. package/dist/compact-analyzer.js.map +1 -0
  10. package/dist/compact-decision.d.ts +19 -0
  11. package/dist/compact-decision.js +47 -0
  12. package/dist/compact-decision.js.map +1 -0
  13. package/dist/compact-executor.d.ts +27 -0
  14. package/dist/compact-executor.js +160 -0
  15. package/dist/compact-executor.js.map +1 -0
  16. package/dist/context-watcher-settings.d.ts +21 -0
  17. package/dist/context-watcher-settings.js +176 -0
  18. package/dist/context-watcher-settings.js.map +1 -0
  19. package/dist/context-watcher.d.ts +70 -0
  20. package/dist/context-watcher.js +1005 -0
  21. package/dist/context-watcher.js.map +1 -0
  22. package/dist/db.d.ts +22 -1
  23. package/dist/db.js +184 -5
  24. package/dist/db.js.map +1 -1
  25. package/dist/hooks/stop-hook.js +30 -1
  26. package/dist/hooks/stop-hook.js.map +1 -1
  27. package/dist/index.js +144 -12
  28. package/dist/index.js.map +1 -1
  29. package/dist/manual-decision.d.ts +23 -0
  30. package/dist/manual-decision.js +67 -0
  31. package/dist/manual-decision.js.map +1 -0
  32. package/dist/prompt-clear.d.ts +8 -0
  33. package/dist/prompt-clear.js +33 -0
  34. package/dist/prompt-clear.js.map +1 -0
  35. package/dist/recovery-watcher.d.ts +6 -0
  36. package/dist/recovery-watcher.js +131 -38
  37. package/dist/recovery-watcher.js.map +1 -1
  38. package/dist/role-collision.d.ts +6 -0
  39. package/dist/role-collision.js +51 -0
  40. package/dist/role-collision.js.map +1 -0
  41. package/dist/scheduler.js +47 -0
  42. package/dist/scheduler.js.map +1 -1
  43. package/dist/ui-server.d.ts +4 -0
  44. package/dist/ui-server.js +899 -20
  45. package/dist/ui-server.js.map +1 -1
  46. package/dist/usage-reader.d.ts +79 -0
  47. package/dist/usage-reader.js +553 -0
  48. package/dist/usage-reader.js.map +1 -0
  49. package/package.json +8 -2
@@ -0,0 +1,1005 @@
1
+ "use strict";
2
+ // Context-hygiene watcher — monitors each live agent's CURRENT context
3
+ // occupancy (read from its Claude Code transcript) and, at a threshold, nudges
4
+ // /compact so the agent stops re-reading a huge context every turn. The meter
5
+ // showed the burn is ~97% cache-reads (re-sent context); this automates the
6
+ // manual /compact discipline a human can't sustain across 15-20 terminals.
7
+ //
8
+ // SAFETY POSTURE (this types into LIVE terminals once armed):
9
+ // - mode "observe" (default): detect + log "would-nudge" + project savings.
10
+ // NEVER injects. Zero risk to the working fleet.
11
+ // - mode "inject" (ARMED, founder-gated): types s.message (default /compact)
12
+ // into a terminal ONLY when ALL gates hold — economic (perTurnCost over
13
+ // threshold) + fresh consent (a signal_checkpoint within K real turns) +
14
+ // idle (last turn ENDED, not mid-turn) + verified handoff + not an
15
+ // ambiguous shared-repo session. With oneShot (default), it fires once then
16
+ // auto-disables (latch) so arming can't become a standing fleet loop.
17
+ // - Settings default to enabled=false: nothing runs/injects until the
18
+ // operator explicitly enables + flips mode to inject.
19
+ //
20
+ // Detection metric: a turn's input-side tokens (input + cache_read +
21
+ // cache_creation) = the prompt size being re-sent = context occupancy.
22
+ // Verified empirically to grow monotonically turn-over-turn (no sawtooth), so
23
+ // a single-tick read is a stable signal.
24
+ var __importDefault = (this && this.__importDefault) || function (mod) {
25
+ return (mod && mod.__esModule) ? mod : { "default": mod };
26
+ };
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.formatGateStatus = formatGateStatus;
29
+ exports.latestTurnEconomics = latestTurnEconomics;
30
+ exports.projectCompactOpportunity = projectCompactOpportunity;
31
+ exports.armGate = armGate;
32
+ exports.startContextWatcher = startContextWatcher;
33
+ const fs_1 = __importDefault(require("fs"));
34
+ const path_1 = __importDefault(require("path"));
35
+ const os_1 = __importDefault(require("os"));
36
+ const readline_1 = __importDefault(require("readline"));
37
+ const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
38
+ const db_js_1 = require("./db.js");
39
+ const usage_reader_js_1 = require("./usage-reader.js");
40
+ const context_watcher_settings_js_1 = require("./context-watcher-settings.js");
41
+ const scheduler_js_1 = require("./scheduler.js");
42
+ const compact_executor_js_1 = require("./compact-executor.js");
43
+ const compact_decision_js_1 = require("./compact-decision.js");
44
+ const manual_decision_js_1 = require("./manual-decision.js");
45
+ const NEXUS_DIR = path_1.default.join(os_1.default.homedir(), ".claudelink");
46
+ const DB_PATH = path_1.default.join(NEXUS_DIR, "nexus.db");
47
+ const LOG_PATH = path_1.default.join(NEXUS_DIR, "context-watcher.log");
48
+ function logLine(line) {
49
+ try {
50
+ fs_1.default.appendFileSync(LOG_PATH, `${new Date().toISOString()} ${line}\n`);
51
+ }
52
+ catch {
53
+ /* logging never breaks the watcher */
54
+ }
55
+ }
56
+ function shortModel(m) {
57
+ return m.replace(/^claude-/, "");
58
+ }
59
+ // Exported for testability — log shape regressions silently break operator
60
+ // analysis tooling, so pinning the schema is worth the small surface area.
61
+ function formatGateStatus(f) {
62
+ const parts = [
63
+ "gate-status",
64
+ `role=${f.role}`,
65
+ `agent_id=${f.agent_id}`,
66
+ `mode=${f.mode}`,
67
+ `decision=${f.decision}`,
68
+ ];
69
+ if (f.reason !== undefined)
70
+ parts.push(`reason=${f.reason}`);
71
+ if (f.context !== undefined)
72
+ parts.push(`context=${f.context}`);
73
+ if (f.occupancy_pct !== undefined)
74
+ parts.push(`occupancy_pct=${f.occupancy_pct.toFixed(1)}`);
75
+ if (f.model !== undefined)
76
+ parts.push(`model=${shortModel(f.model)}`);
77
+ if (f.per_turn_usd !== undefined)
78
+ parts.push(`per_turn_usd=${f.per_turn_usd.toFixed(2)}`);
79
+ if (f.turns_per_hr !== undefined)
80
+ parts.push(`turns_per_hr=${f.turns_per_hr.toFixed(1)}`);
81
+ if (f.progressing !== undefined)
82
+ parts.push(`progressing=${f.progressing}`);
83
+ if (f.signal_age_min !== undefined)
84
+ parts.push(`signal_age_min=${f.signal_age_min === null ? "none" : f.signal_age_min.toFixed(1)}`);
85
+ if (f.signal_age_turns !== undefined)
86
+ parts.push(`signal_age_turns=${f.signal_age_turns}`);
87
+ if (f.safe_to_clear !== undefined)
88
+ parts.push(`safe_to_clear=${f.safe_to_clear}`);
89
+ if (f.economic_gate !== undefined)
90
+ parts.push(`economic_gate=${f.economic_gate}`);
91
+ if (f.ambiguous !== undefined)
92
+ parts.push(`ambiguous=${f.ambiguous}`);
93
+ if (f.allowlisted !== undefined)
94
+ parts.push(`allowlisted=${f.allowlisted}`);
95
+ if (f.idle !== undefined)
96
+ parts.push(`idle=${f.idle}`);
97
+ if (f.fresh_consent !== undefined)
98
+ parts.push(`fresh_consent=${f.fresh_consent}`);
99
+ if (f.handoff_ok !== undefined)
100
+ parts.push(`handoff_ok=${f.handoff_ok}`);
101
+ if (f.net_saved_usd !== undefined)
102
+ parts.push(`net_saved_usd=${f.net_saved_usd.toFixed(2)}`);
103
+ return parts.join(" ");
104
+ }
105
+ function logGateStatus(f) {
106
+ logLine(formatGateStatus(f));
107
+ }
108
+ function isProcessAlive(pid) {
109
+ try {
110
+ process.kill(pid, 0);
111
+ return true;
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ }
117
+ // Safety-gate freshness for the OBSERVE correlation log. A coarse time window;
118
+ // the armed phase uses the precise "within K turns of the signal" test.
119
+ const CHECKPOINT_FRESH_MS = 10 * 60 * 1000;
120
+ // A pending manual override that never reaches a fire (agent stays busy, never
121
+ // consents, terminal closed) expires after this window so a long-forgotten
122
+ // click can't fire hours later.
123
+ const MANUAL_TTL_MS = 30 * 60 * 1000;
124
+ // A terminal whose latest transcript turn is a `user` entry (e.g. parked after a
125
+ // /compact, /cost or other local command — the agent never replies to those) is
126
+ // invisible to the strict idle gate, which only flips on an assistant turn. For
127
+ // the MANUAL override (founder-authorized), treat such a turn as idle-enough
128
+ // once it's been quiet this long: an agent genuinely mid-response writes an
129
+ // assistant turn within seconds, so this age means "parked", not "in-flight".
130
+ const MANUAL_PARKED_SEC = 120;
131
+ // The consent ASK typed for a manual /clear. Stronger than the /compact ask: a
132
+ // /clear is a full context wipe with NO in-place summary, so consent REQUIRES a
133
+ // complete handoff file whose path is passed to signal_checkpoint — the watcher
134
+ // verifies that file exists before it ever fires /clear.
135
+ const MANUAL_CLEAR_ASK = "Manual /clear requested (full context wipe — NO summary is kept). To consent: " +
136
+ "FIRST write a COMPLETE handoff file with everything needed to resume this work, " +
137
+ "THEN call signal_checkpoint with safe_to_clear=true and handoff_path set to that file. " +
138
+ "I will /clear you on the next check ONLY once that handoff is verified. If now is a bad " +
139
+ "moment, call signal_checkpoint with safe_to_clear=false and a one-line note and I'll hold off.";
140
+ // Maximum staleness for a v3-captured transcript_path before we treat it as
141
+ // suspect and fall back to the heuristic + ambiguous flag. Sessions can be
142
+ // replaced under the same TTY without the hook re-firing (Claude Code session
143
+ // restart, /resume to a different session, etc.), and the watcher must NOT
144
+ // inject /compact into a transcript that the captured path is now wrong about.
145
+ // 30 min is generous — a legitimately-active session writes to its transcript
146
+ // well within that window; anything older means the agent is idle (no harm in
147
+ // falling back) or the path is stale (correct to fall back).
148
+ const TRANSCRIPT_STALE_MS = 30 * 60 * 1000;
149
+ // Resolve an agent's session transcript. EXACT path (v3): if the hook captured
150
+ // this agent's transcript_path, use it directly — unambiguous even in a shared
151
+ // repo dir, and the source of truth for injection targeting. Otherwise fall
152
+ // back to the most-recent transcript in the project dir (flagged ambiguous when
153
+ // the dir has >1 recently-active session, since we can't tell which is whose).
154
+ //
155
+ // RECENCY CHECK: even when v3 path is captured, verify the file's mtime is
156
+ // recent (< TRANSCRIPT_STALE_MS) before trusting it. Otherwise a session that
157
+ // was replaced in the same terminal (no re-register fired the hook) could let
158
+ // the watcher score gates against a dead transcript and inject /compact into a
159
+ // LIVE different session in the same terminal. Stale path → drop to the
160
+ // ambiguous heuristic path.
161
+ function resolveSession(agent) {
162
+ if (agent.transcript_path) {
163
+ try {
164
+ const st = fs_1.default.statSync(agent.transcript_path);
165
+ const ageMs = Date.now() - st.mtimeMs;
166
+ if (ageMs < TRANSCRIPT_STALE_MS) {
167
+ return { file: agent.transcript_path, ambiguous: false };
168
+ }
169
+ // Path exists but is stale — fall through and force the heuristic. If
170
+ // the heuristic picks the same file we'll still treat it as ambiguous
171
+ // (the v3 path was the only authority on non-ambiguity).
172
+ }
173
+ catch {
174
+ /* fall through to heuristic */
175
+ }
176
+ }
177
+ return mostRecentSession(agent.pid);
178
+ }
179
+ // The most-recently-modified transcript in an agent's project dir. For a
180
+ // single-agent project this is unambiguously that agent's live session; for a
181
+ // shared project (several agents in one repo) we flag it ambiguous — observe
182
+ // can still read it, but injection must NOT target it until session->agent
183
+ // mapping (v3 transcript_path) is populated for that agent.
184
+ function mostRecentSession(pid) {
185
+ const cwd = (0, usage_reader_js_1.cwdForPid)(pid);
186
+ if (!cwd)
187
+ return null;
188
+ const dir = path_1.default.join(usage_reader_js_1.PROJECTS_DIR, (0, usage_reader_js_1.projectIdFromCwd)(cwd));
189
+ let files;
190
+ try {
191
+ files = fs_1.default
192
+ .readdirSync(dir)
193
+ .filter((f) => f.endsWith(".jsonl"))
194
+ .map((f) => {
195
+ const full = path_1.default.join(dir, f);
196
+ return { file: full, mtime: fs_1.default.statSync(full).mtimeMs };
197
+ });
198
+ }
199
+ catch {
200
+ return null;
201
+ }
202
+ if (files.length === 0)
203
+ return null;
204
+ files.sort((a, b) => b.mtime - a.mtime);
205
+ const recentCutoff = Date.now() - 6 * 60 * 60 * 1000; // 6h
206
+ const recent = files.filter((f) => f.mtime >= recentCutoff);
207
+ return { file: files[0].file, ambiguous: recent.length > 1 };
208
+ }
209
+ function inputSideTokens(u) {
210
+ return ((Number(u.input_tokens) || 0) +
211
+ (Number(u.cache_read_input_tokens) || 0) +
212
+ (Number(u.cache_creation_input_tokens) || 0));
213
+ }
214
+ // Reads the economic state of a session's most-recent real turns: current
215
+ // context occupancy + model (for $/turn pricing) + last activity + recent
216
+ // turns/hour (rate-of-burn). Streams the file keeping a small rolling tail.
217
+ //
218
+ // LATEST-TS GUARD: Claude Code's JSONL transcripts contain interleaved branches
219
+ // (resumes/forks) where a later-written line can have an EARLIER timestamp than
220
+ // some line earlier in the file. So "the last line in the file" is not the same
221
+ // as "the latest turn by time." We must only overwrite contextTokens+model when
222
+ // the line's timestamp is >= the latest we've seen — otherwise the economic
223
+ // gate compares against stale data from an older branch.
224
+ //
225
+ // STREAM CLEANUP: try/finally with explicit rl.close() so a transient error
226
+ // mid-stream doesn't leak the file handle.
227
+ // Exported for testability — the latest-ts guard regression is easy to lose
228
+ // without a pinning test.
229
+ async function latestTurnEconomics(file) {
230
+ let contextTokens = null;
231
+ let model = "";
232
+ let latestTs = -Infinity;
233
+ const recentTs = []; // timestamps of recent real turns (rolling)
234
+ const RECENT = 20;
235
+ const input = fs_1.default.createReadStream(file, { encoding: "utf-8" });
236
+ const rl = readline_1.default.createInterface({ input, crlfDelay: Infinity });
237
+ try {
238
+ for await (const line of rl) {
239
+ if (line.indexOf('"usage"') === -1)
240
+ continue;
241
+ let o;
242
+ try {
243
+ o = JSON.parse(line);
244
+ }
245
+ catch {
246
+ continue;
247
+ }
248
+ const msg = o?.message;
249
+ if (!msg?.usage || !msg?.model || msg.model === "<synthetic>")
250
+ continue;
251
+ const ts = Date.parse(o.timestamp || "");
252
+ if (!Number.isFinite(ts))
253
+ continue;
254
+ if (ts >= latestTs) {
255
+ latestTs = ts;
256
+ contextTokens = inputSideTokens(msg.usage);
257
+ model = msg.model;
258
+ }
259
+ recentTs.push(ts);
260
+ if (recentTs.length > RECENT)
261
+ recentTs.shift();
262
+ }
263
+ }
264
+ finally {
265
+ rl.close();
266
+ input.destroy();
267
+ }
268
+ if (contextTokens === null)
269
+ return null;
270
+ // Use the by-time latest, not the by-position last, for last-activity too —
271
+ // and sort recentTs so the turns/hour denominator reflects real chronology.
272
+ recentTs.sort((a, b) => a - b);
273
+ const lastTurnTs = recentTs.length ? recentTs[recentTs.length - 1] : 0;
274
+ // turns/hour over the recent window (guard against a zero/tiny span).
275
+ let turnsPerHour = 0;
276
+ if (recentTs.length >= 2) {
277
+ const spanMs = recentTs[recentTs.length - 1] - recentTs[0];
278
+ if (spanMs > 1000)
279
+ turnsPerHour = ((recentTs.length - 1) * 3600000) / spanMs;
280
+ }
281
+ return { contextTokens, model, lastTurnTs, turnsPerHour };
282
+ }
283
+ // Retrospective what-if: across the window, for every turn whose context
284
+ // exceeds the threshold, count the excess over the post-compact baseline as
285
+ // tokens that would NOT have been re-read had the session compacted at the
286
+ // threshold. Deduped across forked sessions (message.id + requestId), same as
287
+ // the meter. This is the case-study BASELINE opportunity — explicitly an
288
+ // upper bound.
289
+ async function projectCompactOpportunity(agents, windowDays = 7, thresholdTokens = 200000, compactBaselineTokens = 60000) {
290
+ const windowStartMs = Date.now() - windowDays * 24 * 60 * 60 * 1000;
291
+ const seen = new Set();
292
+ // Group agents by project dir (dedupe shared repos), same as the meter.
293
+ const byProject = new Map();
294
+ for (const a of agents) {
295
+ const cwd = (0, usage_reader_js_1.cwdForPid)(a.pid);
296
+ if (!cwd)
297
+ continue;
298
+ const id = (0, usage_reader_js_1.projectIdFromCwd)(cwd);
299
+ const e = byProject.get(id) || { roles: [], cwd };
300
+ e.roles.push(a.role);
301
+ byProject.set(id, e);
302
+ }
303
+ let flaggedTurns = 0;
304
+ let excess = 0;
305
+ const perProject = [];
306
+ for (const [id, { roles, cwd }] of byProject) {
307
+ const dir = path_1.default.join(usage_reader_js_1.PROJECTS_DIR, id);
308
+ let files = [];
309
+ try {
310
+ files = fs_1.default.readdirSync(dir).filter((f) => f.endsWith(".jsonl")).map((f) => path_1.default.join(dir, f));
311
+ }
312
+ catch {
313
+ continue;
314
+ }
315
+ let pFlagged = 0;
316
+ let pExcess = 0;
317
+ for (const file of files) {
318
+ try {
319
+ if (fs_1.default.statSync(file).mtimeMs < windowStartMs)
320
+ continue;
321
+ }
322
+ catch {
323
+ continue;
324
+ }
325
+ const rl = readline_1.default.createInterface({
326
+ input: fs_1.default.createReadStream(file, { encoding: "utf-8" }),
327
+ crlfDelay: Infinity,
328
+ });
329
+ for await (const line of rl) {
330
+ if (line.indexOf('"usage"') === -1)
331
+ continue;
332
+ let o;
333
+ try {
334
+ o = JSON.parse(line);
335
+ }
336
+ catch {
337
+ continue;
338
+ }
339
+ const msg = o?.message;
340
+ if (!msg?.usage || !msg?.model || msg.model === "<synthetic>")
341
+ continue;
342
+ if (msg.id && o.requestId) {
343
+ const key = msg.id + "::" + o.requestId;
344
+ if (seen.has(key))
345
+ continue;
346
+ seen.add(key);
347
+ }
348
+ const ts = Date.parse(o.timestamp || "");
349
+ if (!Number.isFinite(ts) || ts < windowStartMs)
350
+ continue;
351
+ const ctx = inputSideTokens(msg.usage);
352
+ if (ctx > thresholdTokens) {
353
+ pFlagged++;
354
+ pExcess += Math.max(0, ctx - compactBaselineTokens);
355
+ }
356
+ }
357
+ }
358
+ if (pFlagged > 0) {
359
+ flaggedTurns += pFlagged;
360
+ excess += pExcess;
361
+ perProject.push({
362
+ label: roles.length === 1 ? roles[0] : `${path_1.default.basename(cwd)} (${roles.length} agents)`,
363
+ roles,
364
+ flaggedTurns: pFlagged,
365
+ excessTokens: pExcess,
366
+ });
367
+ }
368
+ }
369
+ perProject.sort((a, b) => b.excessTokens - a.excessTokens);
370
+ return {
371
+ windowDays,
372
+ thresholdTokens,
373
+ compactBaselineTokens,
374
+ flaggedTurns,
375
+ excessTokensUpperBound: excess,
376
+ estUsdUpperBound: (excess * usage_reader_js_1.OPUS_CACHE_READ_PER_MTOK) / 1000000,
377
+ perProject,
378
+ };
379
+ }
380
+ // Armed-inject safety read: is the agent idle (last turn ENDED, quiet ≥15s,
381
+ // not mid-tool-call) and how many real turns has it taken since its consent
382
+ // signal? Streams the session transcript once. Used only in the inject path.
383
+ //
384
+ // LATEST-TS GUARD: same as latestTurnEconomics — JSONL transcripts can have
385
+ // branched/resumed lines whose by-position order is not by-time order. The
386
+ // idle decision must compare against the chronologically-latest turn, not the
387
+ // last line in the file. Without this guard an older "end_turn" line that
388
+ // happens to be last in the file lets the gate think the agent is idle while
389
+ // the live session is mid-tool-call → /compact lands mid-work.
390
+ //
391
+ // STREAM CLEANUP: try/finally with explicit rl.close() + input.destroy().
392
+ // Exported for testability — gate-lying regressions are the most
393
+ // safety-critical thing we can catch in a test.
394
+ async function armGate(file, checkpointTs) {
395
+ let latest = null;
396
+ let turnsSince = 0;
397
+ const input = fs_1.default.createReadStream(file, { encoding: "utf-8" });
398
+ const rl = readline_1.default.createInterface({ input, crlfDelay: Infinity });
399
+ try {
400
+ for await (const line of rl) {
401
+ if (line.indexOf('"type"') === -1)
402
+ continue;
403
+ let o;
404
+ try {
405
+ o = JSON.parse(line);
406
+ }
407
+ catch {
408
+ continue;
409
+ }
410
+ if (o.type !== "user" && o.type !== "assistant")
411
+ continue;
412
+ const m = o.message || {};
413
+ let toolUse = false;
414
+ if (Array.isArray(m.content))
415
+ for (const b of m.content)
416
+ if (b && b.type === "tool_use")
417
+ toolUse = true;
418
+ const ts = Date.parse(o.timestamp || "");
419
+ if (!Number.isFinite(ts))
420
+ continue;
421
+ if (latest === null || ts >= latest.ts) {
422
+ latest = { type: o.type, stop: m.stop_reason ?? null, ts, toolUse };
423
+ }
424
+ if (checkpointTs != null && ts > checkpointTs)
425
+ turnsSince++;
426
+ }
427
+ }
428
+ finally {
429
+ rl.close();
430
+ input.destroy();
431
+ }
432
+ if (!latest)
433
+ return {
434
+ idle: false,
435
+ turnsSinceSignal: 999,
436
+ detail: "no real turns",
437
+ latestType: "none",
438
+ ageSec: Infinity,
439
+ };
440
+ const ageSec = (Date.now() - latest.ts) / 1000;
441
+ const idle = latest.type === "assistant" &&
442
+ (latest.stop === "end_turn" || latest.stop === "stop_sequence") &&
443
+ !latest.toolUse &&
444
+ ageSec >= 15;
445
+ return {
446
+ idle,
447
+ turnsSinceSignal: turnsSince,
448
+ detail: `last=${latest.type}/${latest.stop} age=${ageSec.toFixed(0)}s turnsSinceSignal=${turnsSince}`,
449
+ latestType: latest.type,
450
+ ageSec,
451
+ };
452
+ }
453
+ function startContextWatcher() {
454
+ // SELF-SUFFICIENT MIGRATION. The watcher reads schema-v5 columns
455
+ // (checkpoint_consent_ts) through raw readonly handles and never migrates on
456
+ // its own. Its host (ui-server) doesn't construct a NexusDB either, so absent
457
+ // this, the column would exist only if a NEW-code claudelink-server happened
458
+ // to boot first — a deploy-order race that would make every tick throw "no
459
+ // such column" and silently kill the watcher. Constructing a NexusDB once
460
+ // runs migrations idempotently against the same DB, then we close it and rely
461
+ // on the raw handles. Fail-open: a migration error is logged, not thrown.
462
+ try {
463
+ const migrator = new db_js_1.NexusDB();
464
+ migrator.close();
465
+ }
466
+ catch (e) {
467
+ logLine(`startup-migration-failed: ${e?.message ?? String(e)}`);
468
+ }
469
+ let db = null;
470
+ let timer = null;
471
+ let tickInProgress = false;
472
+ const state = new Map();
473
+ const openDb = () => {
474
+ if (!db)
475
+ db = new better_sqlite3_1.default(DB_PATH, { readonly: true });
476
+ return db;
477
+ };
478
+ // Separate WRITABLE handle, opened lazily, used only to consume an agent's
479
+ // /compact consent after firing (reset safe_to_clear→0, clear the anchor) so
480
+ // one yes maps to one compact. The main handle stays readonly by design; WAL
481
+ // tolerates a concurrent reader + this writer.
482
+ let writeDb = null;
483
+ const consumeConsent = (agentId) => {
484
+ try {
485
+ if (!writeDb) {
486
+ writeDb = new better_sqlite3_1.default(DB_PATH);
487
+ // Match NexusDB: tolerate brief contention with the MCP servers that
488
+ // also write this DB (register / setCheckpoint / messages).
489
+ writeDb.pragma("busy_timeout = 5000");
490
+ }
491
+ writeDb
492
+ .prepare(`UPDATE agents SET checkpoint_safe_to_clear = 0, checkpoint_consent_ts = NULL WHERE id = ?`)
493
+ .run(agentId);
494
+ }
495
+ catch (e) {
496
+ logLine(`consume-consent-failed ${agentId}: ${e?.message ?? String(e)}`);
497
+ }
498
+ };
499
+ // Clear a pending manual override after firing or expiring it, on the same
500
+ // writable handle. One click → one action; the column must not survive a fire.
501
+ const clearManualAction = (agentId) => {
502
+ try {
503
+ if (!writeDb) {
504
+ writeDb = new better_sqlite3_1.default(DB_PATH);
505
+ writeDb.pragma("busy_timeout = 5000");
506
+ }
507
+ writeDb
508
+ .prepare(`UPDATE agents SET manual_action = NULL, manual_action_ts = NULL WHERE id = ?`)
509
+ .run(agentId);
510
+ }
511
+ catch (e) {
512
+ logLine(`clear-manual-failed ${agentId}: ${e?.message ?? String(e)}`);
513
+ }
514
+ };
515
+ const liveAgents = () => {
516
+ const rows = openDb()
517
+ .prepare(`SELECT id, role, pid, tty, terminal_app, pane_id, session_id, transcript_path,
518
+ checkpoint_ts, checkpoint_safe_to_clear, checkpoint_handoff_path,
519
+ checkpoint_consent_ts, manual_action, manual_action_ts
520
+ FROM agents`)
521
+ .all();
522
+ return rows.filter((r) => isProcessAlive(r.pid));
523
+ };
524
+ const tick = async () => {
525
+ const summary = { checked: 0, flagged: 0, wouldNudge: 0, injected: 0 };
526
+ if (tickInProgress) {
527
+ logLine("skip-tick: previous tick still in flight");
528
+ return summary;
529
+ }
530
+ tickInProgress = true;
531
+ try {
532
+ const s = (0, context_watcher_settings_js_1.readContextWatcherSettings)();
533
+ if (!s.enabled)
534
+ return summary;
535
+ const now = Date.now();
536
+ const cooldownMs = s.cooldownMin * 60 * 1000;
537
+ const live = liveAgents();
538
+ // ── MANUAL OVERRIDES ──────────────────────────────────────────────────
539
+ // Founder-clicked Compact/Clear from the Command Center, handled in their
540
+ // own pass — fully independent of the occupancy / economics / allowlist /
541
+ // mode machinery below. A click is explicit per-terminal authorization, so
542
+ // those gates are bypassed; only the SAFETY gates remain (idle, a GENUINE
543
+ // consent that POSTDATES the click, a verified handoff for /clear, and no
544
+ // ambiguous session at FIRE time) plus a TTL so a forgotten click never
545
+ // fires later. Ambiguity does NOT block the ASK — a parked terminal reads
546
+ // ambiguous, and the ask (typed by exact tty) is what wakes it.
547
+ // Same ask + keystroke path as the autonomous handshake.
548
+ for (const a of live) {
549
+ const manual = a.manual_action;
550
+ if (manual !== "compact" && manual !== "clear")
551
+ continue;
552
+ const ckMin = a.checkpoint_ts != null ? (now - a.checkpoint_ts) / 60000 : null;
553
+ const requestedTs = a.manual_action_ts ?? now;
554
+ const ageMs = now - requestedTs;
555
+ const st = state.get(a.id) || { lastNudgedAt: 0, nudgeCount: 0, lastAskedAt: 0 };
556
+ const session = resolveSession(a);
557
+ if (!session) {
558
+ // Can't target it (no live transcript) — keep pending until the TTL
559
+ // mops it up, but log loudly so a click that "did nothing" is visible.
560
+ if (ageMs > MANUAL_TTL_MS) {
561
+ clearManualAction(a.id);
562
+ logLine(`manual-expired ${a.role} action=${manual} (no session)`);
563
+ }
564
+ logGateStatus({
565
+ role: a.role,
566
+ agent_id: a.id,
567
+ mode: s.mode,
568
+ decision: "manual-skip-no-session",
569
+ reason: `${manual}: no live session to target (kept until ttl)`,
570
+ signal_age_min: ckMin,
571
+ safe_to_clear: a.checkpoint_safe_to_clear === 1 ? 1 : 0,
572
+ });
573
+ continue;
574
+ }
575
+ const ag = await armGate(session.file, a.checkpoint_ts);
576
+ const handoffOk = !!a.checkpoint_handoff_path &&
577
+ (0, compact_executor_js_1.verifyHandoff)(a.checkpoint_handoff_path).ok;
578
+ // Relaxed idle for the founder-authorized override: strict idle, OR a
579
+ // terminal parked after a local command (latest=user, quiet > the parked
580
+ // threshold). Safe — the ASK only queues into the input buffer, and FIRE
581
+ // still needs a POSTDATED genuine consent, which requires the agent to
582
+ // have produced an assistant turn (the signal_checkpoint call) first.
583
+ const parkedAfterUser = ag.latestType === "user" && ag.ageSec >= MANUAL_PARKED_SEC;
584
+ const idleEnough = ag.idle || parkedAfterUser;
585
+ const action = (0, manual_decision_js_1.decideManualAction)({
586
+ requested: manual,
587
+ idle: idleEnough,
588
+ ambiguous: session.ambiguous,
589
+ safeToClear: a.checkpoint_safe_to_clear === 1,
590
+ consentTs: a.checkpoint_consent_ts,
591
+ requestedTs,
592
+ handoffOk,
593
+ // The watcher owns the ask, so lastAskedAt is the only ask clock —
594
+ // requestedTs must NOT be reused here or the first prompt waits a full
595
+ // cooldown after the click.
596
+ msSinceLastAsk: st.lastAskedAt > 0 ? now - st.lastAskedAt : null,
597
+ askCooldownMs: cooldownMs,
598
+ ageMs,
599
+ ttlMs: MANUAL_TTL_MS,
600
+ });
601
+ const candidate = {
602
+ id: a.id,
603
+ role: a.role,
604
+ tty: a.tty || "",
605
+ terminal_app: a.terminal_app,
606
+ pane_id: a.pane_id,
607
+ pid: a.pid,
608
+ };
609
+ const detail = parkedAfterUser ? `${ag.detail} parked=true` : ag.detail;
610
+ const mFields = {
611
+ role: a.role,
612
+ agent_id: a.id,
613
+ mode: s.mode,
614
+ idle: idleEnough,
615
+ ambiguous: session.ambiguous,
616
+ handoff_ok: handoffOk,
617
+ signal_age_min: ckMin,
618
+ signal_age_turns: ag.turnsSinceSignal,
619
+ safe_to_clear: a.checkpoint_safe_to_clear === 1 ? 1 : 0,
620
+ };
621
+ if (action.kind === "skip") {
622
+ if (action.reason === "expired") {
623
+ clearManualAction(a.id);
624
+ logLine(`manual-expired ${a.role} action=${manual}`);
625
+ }
626
+ logGateStatus({
627
+ ...mFields,
628
+ decision: "manual-skip",
629
+ reason: `${manual}: ${action.reason} (${detail})`,
630
+ });
631
+ continue;
632
+ }
633
+ if (action.kind === "ask") {
634
+ const askMsg = manual === "clear" ? MANUAL_CLEAR_ASK : s.askMessage;
635
+ const result = (0, scheduler_js_1.injectKeystroke)(candidate, askMsg);
636
+ // Arm the re-ask cooldown only when the prompt actually landed (ok), or
637
+ // can never land (skip = unsupported terminal — permanent, don't retry
638
+ // every tick). A "fail" is a TRANSIENT iTerm2 timeout (osascript queued
639
+ // behind TUI rendering); leave lastAskedAt untouched so the NEXT tick
640
+ // (~30s) retries instead of stalling a full cooldown — bounded by the
641
+ // manual TTL. Without this, one timed-out inject silently parks the
642
+ // terminal for the whole cooldown.
643
+ if (result !== "fail")
644
+ st.lastAskedAt = now;
645
+ state.set(a.id, st);
646
+ logGateStatus({
647
+ ...mFields,
648
+ decision: "manual-ask-consent",
649
+ reason: `${manual}: asked for consent result=${result}`,
650
+ });
651
+ continue;
652
+ }
653
+ // action.kind === "fire" — postdated consent + idle (+ verified handoff
654
+ // for /clear). CONSUME consent and CLEAR the request only on a successful
655
+ // keystroke, so one yes maps to one action and a failed inject is retried
656
+ // next tick instead of silently dropped.
657
+ const result = (0, scheduler_js_1.injectKeystroke)(candidate, action.command);
658
+ if (result === "ok") {
659
+ consumeConsent(a.id);
660
+ clearManualAction(a.id);
661
+ summary.injected++;
662
+ }
663
+ logGateStatus({
664
+ ...mFields,
665
+ decision: "manual-FIRE",
666
+ reason: `action=${action.command} result=${result} ` +
667
+ (result === "ok"
668
+ ? "consumed=consent cleared=manual"
669
+ : "(kept pending — retry next tick)"),
670
+ });
671
+ continue;
672
+ }
673
+ for (const a of live) {
674
+ if (a.manual_action === "compact" || a.manual_action === "clear")
675
+ continue; // owned by the manual-override pass above
676
+ summary.checked++;
677
+ // Common header — every emitted gate-status line carries these.
678
+ const ckMinNum = a.checkpoint_ts != null ? (now - a.checkpoint_ts) / 60000 : null;
679
+ const safetyFresh = a.checkpoint_ts != null && now - a.checkpoint_ts < CHECKPOINT_FRESH_MS;
680
+ const session = resolveSession(a);
681
+ if (!session) {
682
+ logGateStatus({
683
+ role: a.role,
684
+ agent_id: a.id,
685
+ mode: s.mode,
686
+ decision: "skip-no-session",
687
+ reason: "resolveSession returned null (no transcript dir / stale path / no live session)",
688
+ signal_age_min: ckMinNum,
689
+ safe_to_clear: a.checkpoint_safe_to_clear === 1 ? 1 : 0,
690
+ });
691
+ continue;
692
+ }
693
+ let econ;
694
+ try {
695
+ econ = await latestTurnEconomics(session.file);
696
+ }
697
+ catch (e) {
698
+ logGateStatus({
699
+ role: a.role,
700
+ agent_id: a.id,
701
+ mode: s.mode,
702
+ decision: "skip-econ-error",
703
+ reason: e?.message ?? String(e),
704
+ ambiguous: session.ambiguous,
705
+ signal_age_min: ckMinNum,
706
+ safe_to_clear: a.checkpoint_safe_to_clear === 1 ? 1 : 0,
707
+ });
708
+ continue;
709
+ }
710
+ if (econ === null) {
711
+ logGateStatus({
712
+ role: a.role,
713
+ agent_id: a.id,
714
+ mode: s.mode,
715
+ decision: "skip-no-econ",
716
+ reason: "no real assistant-with-usage turns in transcript",
717
+ ambiguous: session.ambiguous,
718
+ signal_age_min: ckMinNum,
719
+ safe_to_clear: a.checkpoint_safe_to_clear === 1 ? 1 : 0,
720
+ });
721
+ continue;
722
+ }
723
+ // PROPORTIONAL OCCUPANCY TRIGGER (Stage 0 replacement for the
724
+ // model-blind dollar gate). Compares current context tokens against
725
+ // this model's window: e.g. 100K input-side tokens on a 200K-window
726
+ // model = 50% occupancy, which is the default arming threshold. The
727
+ // dollar value is still computed for observability but no longer
728
+ // arms by itself.
729
+ const windowTokens = (0, usage_reader_js_1.effectiveContextWindow)(econ.model, econ.contextTokens);
730
+ const occupancyFrac = econ.contextTokens / windowTokens;
731
+ const occupancyPct = occupancyFrac * 100;
732
+ const cacheReadPrice = (0, usage_reader_js_1.cacheReadPricePerMtok)(econ.model);
733
+ const perTurnCostUsd = (econ.contextTokens * cacheReadPrice) / 1000000;
734
+ if (occupancyFrac <= s.contextOccupancyThreshold) {
735
+ logGateStatus({
736
+ role: a.role,
737
+ agent_id: a.id,
738
+ mode: s.mode,
739
+ decision: "below-threshold",
740
+ context: econ.contextTokens,
741
+ occupancy_pct: occupancyPct,
742
+ model: econ.model,
743
+ per_turn_usd: perTurnCostUsd,
744
+ turns_per_hr: econ.turnsPerHour,
745
+ ambiguous: session.ambiguous,
746
+ signal_age_min: ckMinNum,
747
+ safe_to_clear: a.checkpoint_safe_to_clear === 1 ? 1 : 0,
748
+ });
749
+ continue;
750
+ }
751
+ summary.flagged++;
752
+ // Per-agent throttle state, keyed by agent_id (NOT role): duplicate
753
+ // live roles must not share one cooldown/ask counter. The cooldown is
754
+ // applied per-branch below — it throttles ASKING (and observe nudges),
755
+ // never the FIRE, which rests on durable DB consent.
756
+ const st = state.get(a.id) || { lastNudgedAt: 0, nudgeCount: 0, lastAskedAt: 0 };
757
+ // ── DECISION (fires): only act when forward savings clearly beat the
758
+ // handshake overhead, AND the session is actively progressing (else a
759
+ // session about to idle pays overhead for nothing), weighting rate-of-burn.
760
+ const activelyProgressing = now - econ.lastTurnTs < s.activeWindowMin * 60 * 1000;
761
+ // Forward exposure horizon: rate-of-burn over the active window (how many
762
+ // more turns we'd expect to re-read this context before a natural stop).
763
+ // Conservative: cap at the turns the active window could hold.
764
+ const horizonTurns = Math.max(1, Math.min((econ.turnsPerHour * s.activeWindowMin) / 60, s.activeWindowMin // hard cap so a runaway rate can't inflate savings
765
+ ));
766
+ const savedTokensPerTurn = Math.max(0, econ.contextTokens - s.compactBaselineTokens);
767
+ const forwardSavedTokens = savedTokensPerTurn * horizonTurns;
768
+ const netSavedTokens = forwardSavedTokens - s.handshakeOverheadTokens;
769
+ const netSavedUsd = (netSavedTokens * cacheReadPrice) / 1000000;
770
+ const netPositive = netSavedTokens > 0;
771
+ const economicGreen = activelyProgressing && netPositive;
772
+ // Common fields shared by every remaining decision branch.
773
+ const commonFields = {
774
+ role: a.role,
775
+ agent_id: a.id,
776
+ mode: s.mode,
777
+ context: econ.contextTokens,
778
+ occupancy_pct: occupancyPct,
779
+ model: econ.model,
780
+ per_turn_usd: perTurnCostUsd,
781
+ turns_per_hr: econ.turnsPerHour,
782
+ progressing: activelyProgressing,
783
+ ambiguous: session.ambiguous,
784
+ signal_age_min: ckMinNum,
785
+ safe_to_clear: a.checkpoint_safe_to_clear === 1 ? 1 : 0,
786
+ economic_gate: economicGreen,
787
+ net_saved_usd: netSavedUsd,
788
+ };
789
+ if (s.mode === "observe") {
790
+ // Nudge path: throttle re-nudges, then gate on the session being
791
+ // actively progressing (don't pay handshake overhead on one about to
792
+ // idle) + net-positive.
793
+ if (now - st.lastNudgedAt < cooldownMs) {
794
+ logGateStatus({
795
+ ...commonFields,
796
+ decision: "skip-cooldown",
797
+ reason: `nudgedAt=${new Date(st.lastNudgedAt).toISOString()} cooldownMin=${s.cooldownMin}`,
798
+ });
799
+ continue;
800
+ }
801
+ if (!(activelyProgressing && netPositive)) {
802
+ logGateStatus({
803
+ ...commonFields,
804
+ decision: "observe-hold",
805
+ reason: !activelyProgressing ? "idle" : "savings<overhead",
806
+ });
807
+ continue;
808
+ }
809
+ summary.wouldNudge++;
810
+ st.lastNudgedAt = now;
811
+ st.nudgeCount++;
812
+ state.set(a.id, st);
813
+ logGateStatus({
814
+ ...commonFields,
815
+ decision: "would-nudge",
816
+ reason: `count=${st.nudgeCount} safety_fresh=${safetyFresh}`,
817
+ });
818
+ }
819
+ else {
820
+ // ARMED INJECT (mode "inject"). FAIL-CLOSED ALLOWLIST FIRST: only
821
+ // roles the operator explicitly put on injectAllowlist are ever
822
+ // auto-compacted. An empty/unset list arms NO ONE — this is what makes
823
+ // a standing-on rollout a controlled subset, not fleet-wide. Checked
824
+ // before the transcript scan so non-listed agents are skipped cheaply.
825
+ const allowlisted = s.injectAllowlist.includes(a.role);
826
+ if (!allowlisted) {
827
+ logGateStatus({
828
+ ...commonFields,
829
+ decision: "inject-skip-allowlist",
830
+ reason: `allowlist=${JSON.stringify(s.injectAllowlist)}`,
831
+ allowlisted: false,
832
+ });
833
+ continue;
834
+ }
835
+ // T1 CONSENT HANDSHAKE. The armed path no longer silently fires
836
+ // /compact on mere turn-boundary freshness (which the Stop hook
837
+ // satisfies every turn — the agent never actually consented). It now:
838
+ // FIRE — only on a GENUINE consent (checkpoint_safe_to_clear===1,
839
+ // set solely by an explicit signal_checkpoint(true) and
840
+ // unforgeable by the per-turn hook touch) that is still fresh
841
+ // (within consentFreshMin) AND the agent is idle right now;
842
+ // ASK — when occupancy is high + idle + economically worth a turn
843
+ // and no fire-eligible consent exists, type askMessage to
844
+ // request that consent (throttled by cooldown).
845
+ // Never act on an ambiguous shared-repo session.
846
+ //
847
+ // handoff_ok is STILL computed + logged for visibility but is NOT a
848
+ // gate: /compact is soft-recoverable (it summarizes in place; the idle
849
+ // gate prevents mid-tool-call interruption), so the ask ENCOURAGES a
850
+ // handoff update without hard-gating on it. handoff_ok stays the
851
+ // stronger gate reserved for the future destructive /clear path.
852
+ const ag = await armGate(session.file, a.checkpoint_ts);
853
+ const handoffOk = !!a.checkpoint_handoff_path && (0, compact_executor_js_1.verifyHandoff)(a.checkpoint_handoff_path).ok;
854
+ const consentFreshMs = s.consentFreshMin * 60 * 1000;
855
+ const consentAgeMs = a.checkpoint_consent_ts != null ? now - a.checkpoint_consent_ts : null;
856
+ const consentFresh = a.checkpoint_safe_to_clear === 1 &&
857
+ consentAgeMs !== null &&
858
+ consentAgeMs < consentFreshMs;
859
+ const action = (0, compact_decision_js_1.decideCompactAction)({
860
+ idle: ag.idle,
861
+ ambiguous: session.ambiguous,
862
+ safeToClear: a.checkpoint_safe_to_clear === 1,
863
+ consentAgeMs,
864
+ consentFreshMs,
865
+ economicGreen,
866
+ msSinceLastAsk: st.lastAskedAt > 0 ? now - st.lastAskedAt : null,
867
+ askCooldownMs: cooldownMs,
868
+ });
869
+ const candidate = {
870
+ id: a.id,
871
+ role: a.role,
872
+ tty: a.tty || "",
873
+ terminal_app: a.terminal_app,
874
+ pane_id: a.pane_id,
875
+ pid: a.pid,
876
+ };
877
+ const injectFields = {
878
+ allowlisted: true,
879
+ idle: ag.idle,
880
+ fresh_consent: consentFresh,
881
+ handoff_ok: handoffOk,
882
+ signal_age_turns: ag.turnsSinceSignal,
883
+ };
884
+ if (action.kind === "skip") {
885
+ logGateStatus({
886
+ ...commonFields,
887
+ ...injectFields,
888
+ decision: "inject-skip-safety",
889
+ reason: `${action.reason} (${ag.detail})`,
890
+ });
891
+ continue;
892
+ }
893
+ if (action.kind === "ask") {
894
+ // Asking is NOT the irreversible action — do NOT latch oneShot here.
895
+ const result = (0, scheduler_js_1.injectKeystroke)(candidate, s.askMessage);
896
+ st.lastAskedAt = now;
897
+ state.set(a.id, st);
898
+ logGateStatus({
899
+ ...commonFields,
900
+ ...injectFields,
901
+ decision: "inject-ask-consent",
902
+ reason: `asked for consent result=${result}`,
903
+ });
904
+ continue;
905
+ }
906
+ // action.kind === "fire" — a genuine, fresh consent + idle now.
907
+ // LATCH FIRST: persist enabled=false BEFORE the irreversible /compact.
908
+ // If the process crashes between inject and latch it must NOT re-fire
909
+ // on restart, so the latch lands first. A failed inject is recoverable
910
+ // (re-enable manually); a double-/compact is not.
911
+ //
912
+ // LATCH FAILURE GUARD: writeContextWatcherSettings can throw on a full
913
+ // disk / permission error. If the latch fails we MUST NOT proceed —
914
+ // skip this agent and log loudly.
915
+ if (s.oneShot) {
916
+ try {
917
+ (0, context_watcher_settings_js_1.writeContextWatcherSettings)({ enabled: false });
918
+ logLine(`ONE-SHOT LATCH set (enabled=false) before firing ${a.role}`);
919
+ }
920
+ catch (e) {
921
+ logGateStatus({
922
+ ...commonFields,
923
+ ...injectFields,
924
+ decision: "inject-LATCH-FAILED",
925
+ reason: e?.message ?? String(e),
926
+ });
927
+ continue;
928
+ }
929
+ }
930
+ const result = (0, scheduler_js_1.injectKeystroke)(candidate, s.message);
931
+ // CONSUME the consent only on a SUCCESSFUL inject: one yes → one
932
+ // compact. Post-/compact the flag would otherwise still read 1 and
933
+ // re-fire next tick (non-oneShot); this also stops a stale yes firing
934
+ // after restart. Gated on "ok" so a failed keystroke (skip/fail)
935
+ // preserves the consent for a legitimate retry instead of silently
936
+ // burning it (oneShot's latch masks this today, but non-oneShot
937
+ // would otherwise drop the compact entirely).
938
+ if (result === "ok")
939
+ consumeConsent(a.id);
940
+ summary.injected++;
941
+ st.lastNudgedAt = now;
942
+ st.nudgeCount++;
943
+ state.set(a.id, st);
944
+ logGateStatus({
945
+ ...commonFields,
946
+ ...injectFields,
947
+ decision: "inject-ARMED-FIRE",
948
+ reason: `action=${JSON.stringify(s.message)} result=${result} consumed=consent`,
949
+ });
950
+ if (s.oneShot)
951
+ break;
952
+ }
953
+ }
954
+ return summary;
955
+ }
956
+ finally {
957
+ tickInProgress = false;
958
+ }
959
+ };
960
+ const reschedule = () => {
961
+ const s = (0, context_watcher_settings_js_1.readContextWatcherSettings)();
962
+ if (timer) {
963
+ clearInterval(timer);
964
+ timer = null;
965
+ }
966
+ if (!s.enabled) {
967
+ logLine("reschedule: disabled");
968
+ return;
969
+ }
970
+ timer = setInterval(() => {
971
+ tick().catch((e) => logLine(`tick-uncaught: ${e?.message ?? String(e)}`));
972
+ }, s.intervalSec * 1000);
973
+ logLine(`reschedule: enabled mode=${s.mode} intervalSec=${s.intervalSec} ` +
974
+ `occupancyThreshold=${s.contextOccupancyThreshold} ` +
975
+ `dollarPerTurn(legacy)=${s.dollarPerTurnThreshold} ` +
976
+ `cooldownMin=${s.cooldownMin}`);
977
+ };
978
+ const stop = () => {
979
+ if (timer) {
980
+ clearInterval(timer);
981
+ timer = null;
982
+ }
983
+ if (db) {
984
+ try {
985
+ db.close();
986
+ }
987
+ catch {
988
+ /* ignore */
989
+ }
990
+ db = null;
991
+ }
992
+ if (writeDb) {
993
+ try {
994
+ writeDb.close();
995
+ }
996
+ catch {
997
+ /* ignore */
998
+ }
999
+ writeDb = null;
1000
+ }
1001
+ };
1002
+ reschedule();
1003
+ return { reschedule, stop, tickNow: tick };
1004
+ }
1005
+ //# sourceMappingURL=context-watcher.js.map