claudelink 1.4.3 → 1.5.1
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 +58 -4
- package/dist/arm-compact.d.ts +2 -0
- package/dist/arm-compact.js +182 -0
- package/dist/arm-compact.js.map +1 -0
- package/dist/cli.js +145 -0
- package/dist/cli.js.map +1 -1
- package/dist/compact-analyzer.d.ts +67 -0
- package/dist/compact-analyzer.js +309 -0
- package/dist/compact-analyzer.js.map +1 -0
- package/dist/compact-decision.d.ts +19 -0
- package/dist/compact-decision.js +47 -0
- package/dist/compact-decision.js.map +1 -0
- package/dist/compact-executor.d.ts +27 -0
- package/dist/compact-executor.js +160 -0
- package/dist/compact-executor.js.map +1 -0
- package/dist/context-watcher-settings.d.ts +21 -0
- package/dist/context-watcher-settings.js +176 -0
- package/dist/context-watcher-settings.js.map +1 -0
- package/dist/context-watcher.d.ts +70 -0
- package/dist/context-watcher.js +1035 -0
- package/dist/context-watcher.js.map +1 -0
- package/dist/db.d.ts +22 -1
- package/dist/db.js +184 -5
- package/dist/db.js.map +1 -1
- package/dist/hooks/stop-hook.js +30 -1
- package/dist/hooks/stop-hook.js.map +1 -1
- package/dist/index.js +144 -12
- package/dist/index.js.map +1 -1
- package/dist/manual-decision.d.ts +23 -0
- package/dist/manual-decision.js +67 -0
- package/dist/manual-decision.js.map +1 -0
- package/dist/prompt-clear.d.ts +8 -0
- package/dist/prompt-clear.js +33 -0
- package/dist/prompt-clear.js.map +1 -0
- package/dist/recovery-watcher.d.ts +6 -0
- package/dist/recovery-watcher.js +20 -0
- package/dist/recovery-watcher.js.map +1 -1
- package/dist/role-collision.d.ts +6 -0
- package/dist/role-collision.js +51 -0
- package/dist/role-collision.js.map +1 -0
- package/dist/ui-server.d.ts +4 -0
- package/dist/ui-server.js +899 -20
- package/dist/ui-server.js.map +1 -1
- package/dist/usage-reader.d.ts +79 -0
- package/dist/usage-reader.js +553 -0
- package/dist/usage-reader.js.map +1 -0
- package/package.json +8 -2
|
@@ -0,0 +1,1035 @@
|
|
|
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
|
+
// COMPACT-BOUNDARY: a `/compact` writes a `system/compact_boundary` line
|
|
240
|
+
// carrying the real post-compact size in `compactMetadata.postTokens`, but
|
|
241
|
+
// NO `usage` line until the agent's next model turn. On an idle terminal
|
|
242
|
+
// that just compacted, the chronologically-last `usage` line is the stale
|
|
243
|
+
// pre-compact turn — so without this branch we report the pre-compact
|
|
244
|
+
// occupancy (e.g. 164K/82%) until the next turn writes a fresh usage line.
|
|
245
|
+
// Honor the boundary as the latest occupancy datum. Must be checked BEFORE
|
|
246
|
+
// the `"usage"` skip below (the boundary line carries no usage). Same
|
|
247
|
+
// latest-ts guard as usage lines, and the same treatment as the timeline
|
|
248
|
+
// reader (scanFileForTimeline). A real post-compact turn arrives with a
|
|
249
|
+
// later ts and correctly re-overrides — self-healing.
|
|
250
|
+
if (line.indexOf("compact_boundary") !== -1) {
|
|
251
|
+
try {
|
|
252
|
+
const o = JSON.parse(line);
|
|
253
|
+
if (o?.type === "system" && o?.subtype === "compact_boundary") {
|
|
254
|
+
const post = o.compactMetadata?.postTokens;
|
|
255
|
+
const cts = Date.parse(o.timestamp || "");
|
|
256
|
+
if (Number.isFinite(cts) && cts >= latestTs && post != null) {
|
|
257
|
+
latestTs = cts;
|
|
258
|
+
contextTokens = Number(post) || 0;
|
|
259
|
+
// model carries over from the last real turn — a compact cannot
|
|
260
|
+
// occur on a session with no prior model turn, so it stays valid.
|
|
261
|
+
}
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
/* fall through to usage handling */
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (line.indexOf('"usage"') === -1)
|
|
270
|
+
continue;
|
|
271
|
+
let o;
|
|
272
|
+
try {
|
|
273
|
+
o = JSON.parse(line);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const msg = o?.message;
|
|
279
|
+
if (!msg?.usage || !msg?.model || msg.model === "<synthetic>")
|
|
280
|
+
continue;
|
|
281
|
+
const ts = Date.parse(o.timestamp || "");
|
|
282
|
+
if (!Number.isFinite(ts))
|
|
283
|
+
continue;
|
|
284
|
+
if (ts >= latestTs) {
|
|
285
|
+
latestTs = ts;
|
|
286
|
+
contextTokens = inputSideTokens(msg.usage);
|
|
287
|
+
model = msg.model;
|
|
288
|
+
}
|
|
289
|
+
recentTs.push(ts);
|
|
290
|
+
if (recentTs.length > RECENT)
|
|
291
|
+
recentTs.shift();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
rl.close();
|
|
296
|
+
input.destroy();
|
|
297
|
+
}
|
|
298
|
+
if (contextTokens === null)
|
|
299
|
+
return null;
|
|
300
|
+
// Use the by-time latest, not the by-position last, for last-activity too —
|
|
301
|
+
// and sort recentTs so the turns/hour denominator reflects real chronology.
|
|
302
|
+
recentTs.sort((a, b) => a - b);
|
|
303
|
+
const lastTurnTs = recentTs.length ? recentTs[recentTs.length - 1] : 0;
|
|
304
|
+
// turns/hour over the recent window (guard against a zero/tiny span).
|
|
305
|
+
let turnsPerHour = 0;
|
|
306
|
+
if (recentTs.length >= 2) {
|
|
307
|
+
const spanMs = recentTs[recentTs.length - 1] - recentTs[0];
|
|
308
|
+
if (spanMs > 1000)
|
|
309
|
+
turnsPerHour = ((recentTs.length - 1) * 3600000) / spanMs;
|
|
310
|
+
}
|
|
311
|
+
return { contextTokens, model, lastTurnTs, turnsPerHour };
|
|
312
|
+
}
|
|
313
|
+
// Retrospective what-if: across the window, for every turn whose context
|
|
314
|
+
// exceeds the threshold, count the excess over the post-compact baseline as
|
|
315
|
+
// tokens that would NOT have been re-read had the session compacted at the
|
|
316
|
+
// threshold. Deduped across forked sessions (message.id + requestId), same as
|
|
317
|
+
// the meter. This is the case-study BASELINE opportunity — explicitly an
|
|
318
|
+
// upper bound.
|
|
319
|
+
async function projectCompactOpportunity(agents, windowDays = 7, thresholdTokens = 200000, compactBaselineTokens = 60000) {
|
|
320
|
+
const windowStartMs = Date.now() - windowDays * 24 * 60 * 60 * 1000;
|
|
321
|
+
const seen = new Set();
|
|
322
|
+
// Group agents by project dir (dedupe shared repos), same as the meter.
|
|
323
|
+
const byProject = new Map();
|
|
324
|
+
for (const a of agents) {
|
|
325
|
+
const cwd = (0, usage_reader_js_1.cwdForPid)(a.pid);
|
|
326
|
+
if (!cwd)
|
|
327
|
+
continue;
|
|
328
|
+
const id = (0, usage_reader_js_1.projectIdFromCwd)(cwd);
|
|
329
|
+
const e = byProject.get(id) || { roles: [], cwd };
|
|
330
|
+
e.roles.push(a.role);
|
|
331
|
+
byProject.set(id, e);
|
|
332
|
+
}
|
|
333
|
+
let flaggedTurns = 0;
|
|
334
|
+
let excess = 0;
|
|
335
|
+
const perProject = [];
|
|
336
|
+
for (const [id, { roles, cwd }] of byProject) {
|
|
337
|
+
const dir = path_1.default.join(usage_reader_js_1.PROJECTS_DIR, id);
|
|
338
|
+
let files = [];
|
|
339
|
+
try {
|
|
340
|
+
files = fs_1.default.readdirSync(dir).filter((f) => f.endsWith(".jsonl")).map((f) => path_1.default.join(dir, f));
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
let pFlagged = 0;
|
|
346
|
+
let pExcess = 0;
|
|
347
|
+
for (const file of files) {
|
|
348
|
+
try {
|
|
349
|
+
if (fs_1.default.statSync(file).mtimeMs < windowStartMs)
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const rl = readline_1.default.createInterface({
|
|
356
|
+
input: fs_1.default.createReadStream(file, { encoding: "utf-8" }),
|
|
357
|
+
crlfDelay: Infinity,
|
|
358
|
+
});
|
|
359
|
+
for await (const line of rl) {
|
|
360
|
+
if (line.indexOf('"usage"') === -1)
|
|
361
|
+
continue;
|
|
362
|
+
let o;
|
|
363
|
+
try {
|
|
364
|
+
o = JSON.parse(line);
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const msg = o?.message;
|
|
370
|
+
if (!msg?.usage || !msg?.model || msg.model === "<synthetic>")
|
|
371
|
+
continue;
|
|
372
|
+
if (msg.id && o.requestId) {
|
|
373
|
+
const key = msg.id + "::" + o.requestId;
|
|
374
|
+
if (seen.has(key))
|
|
375
|
+
continue;
|
|
376
|
+
seen.add(key);
|
|
377
|
+
}
|
|
378
|
+
const ts = Date.parse(o.timestamp || "");
|
|
379
|
+
if (!Number.isFinite(ts) || ts < windowStartMs)
|
|
380
|
+
continue;
|
|
381
|
+
const ctx = inputSideTokens(msg.usage);
|
|
382
|
+
if (ctx > thresholdTokens) {
|
|
383
|
+
pFlagged++;
|
|
384
|
+
pExcess += Math.max(0, ctx - compactBaselineTokens);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (pFlagged > 0) {
|
|
389
|
+
flaggedTurns += pFlagged;
|
|
390
|
+
excess += pExcess;
|
|
391
|
+
perProject.push({
|
|
392
|
+
label: roles.length === 1 ? roles[0] : `${path_1.default.basename(cwd)} (${roles.length} agents)`,
|
|
393
|
+
roles,
|
|
394
|
+
flaggedTurns: pFlagged,
|
|
395
|
+
excessTokens: pExcess,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
perProject.sort((a, b) => b.excessTokens - a.excessTokens);
|
|
400
|
+
return {
|
|
401
|
+
windowDays,
|
|
402
|
+
thresholdTokens,
|
|
403
|
+
compactBaselineTokens,
|
|
404
|
+
flaggedTurns,
|
|
405
|
+
excessTokensUpperBound: excess,
|
|
406
|
+
estUsdUpperBound: (excess * usage_reader_js_1.OPUS_CACHE_READ_PER_MTOK) / 1000000,
|
|
407
|
+
perProject,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
// Armed-inject safety read: is the agent idle (last turn ENDED, quiet ≥15s,
|
|
411
|
+
// not mid-tool-call) and how many real turns has it taken since its consent
|
|
412
|
+
// signal? Streams the session transcript once. Used only in the inject path.
|
|
413
|
+
//
|
|
414
|
+
// LATEST-TS GUARD: same as latestTurnEconomics — JSONL transcripts can have
|
|
415
|
+
// branched/resumed lines whose by-position order is not by-time order. The
|
|
416
|
+
// idle decision must compare against the chronologically-latest turn, not the
|
|
417
|
+
// last line in the file. Without this guard an older "end_turn" line that
|
|
418
|
+
// happens to be last in the file lets the gate think the agent is idle while
|
|
419
|
+
// the live session is mid-tool-call → /compact lands mid-work.
|
|
420
|
+
//
|
|
421
|
+
// STREAM CLEANUP: try/finally with explicit rl.close() + input.destroy().
|
|
422
|
+
// Exported for testability — gate-lying regressions are the most
|
|
423
|
+
// safety-critical thing we can catch in a test.
|
|
424
|
+
async function armGate(file, checkpointTs) {
|
|
425
|
+
let latest = null;
|
|
426
|
+
let turnsSince = 0;
|
|
427
|
+
const input = fs_1.default.createReadStream(file, { encoding: "utf-8" });
|
|
428
|
+
const rl = readline_1.default.createInterface({ input, crlfDelay: Infinity });
|
|
429
|
+
try {
|
|
430
|
+
for await (const line of rl) {
|
|
431
|
+
if (line.indexOf('"type"') === -1)
|
|
432
|
+
continue;
|
|
433
|
+
let o;
|
|
434
|
+
try {
|
|
435
|
+
o = JSON.parse(line);
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (o.type !== "user" && o.type !== "assistant")
|
|
441
|
+
continue;
|
|
442
|
+
const m = o.message || {};
|
|
443
|
+
let toolUse = false;
|
|
444
|
+
if (Array.isArray(m.content))
|
|
445
|
+
for (const b of m.content)
|
|
446
|
+
if (b && b.type === "tool_use")
|
|
447
|
+
toolUse = true;
|
|
448
|
+
const ts = Date.parse(o.timestamp || "");
|
|
449
|
+
if (!Number.isFinite(ts))
|
|
450
|
+
continue;
|
|
451
|
+
if (latest === null || ts >= latest.ts) {
|
|
452
|
+
latest = { type: o.type, stop: m.stop_reason ?? null, ts, toolUse };
|
|
453
|
+
}
|
|
454
|
+
if (checkpointTs != null && ts > checkpointTs)
|
|
455
|
+
turnsSince++;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
finally {
|
|
459
|
+
rl.close();
|
|
460
|
+
input.destroy();
|
|
461
|
+
}
|
|
462
|
+
if (!latest)
|
|
463
|
+
return {
|
|
464
|
+
idle: false,
|
|
465
|
+
turnsSinceSignal: 999,
|
|
466
|
+
detail: "no real turns",
|
|
467
|
+
latestType: "none",
|
|
468
|
+
ageSec: Infinity,
|
|
469
|
+
};
|
|
470
|
+
const ageSec = (Date.now() - latest.ts) / 1000;
|
|
471
|
+
const idle = latest.type === "assistant" &&
|
|
472
|
+
(latest.stop === "end_turn" || latest.stop === "stop_sequence") &&
|
|
473
|
+
!latest.toolUse &&
|
|
474
|
+
ageSec >= 15;
|
|
475
|
+
return {
|
|
476
|
+
idle,
|
|
477
|
+
turnsSinceSignal: turnsSince,
|
|
478
|
+
detail: `last=${latest.type}/${latest.stop} age=${ageSec.toFixed(0)}s turnsSinceSignal=${turnsSince}`,
|
|
479
|
+
latestType: latest.type,
|
|
480
|
+
ageSec,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
function startContextWatcher() {
|
|
484
|
+
// SELF-SUFFICIENT MIGRATION. The watcher reads schema-v5 columns
|
|
485
|
+
// (checkpoint_consent_ts) through raw readonly handles and never migrates on
|
|
486
|
+
// its own. Its host (ui-server) doesn't construct a NexusDB either, so absent
|
|
487
|
+
// this, the column would exist only if a NEW-code claudelink-server happened
|
|
488
|
+
// to boot first — a deploy-order race that would make every tick throw "no
|
|
489
|
+
// such column" and silently kill the watcher. Constructing a NexusDB once
|
|
490
|
+
// runs migrations idempotently against the same DB, then we close it and rely
|
|
491
|
+
// on the raw handles. Fail-open: a migration error is logged, not thrown.
|
|
492
|
+
try {
|
|
493
|
+
const migrator = new db_js_1.NexusDB();
|
|
494
|
+
migrator.close();
|
|
495
|
+
}
|
|
496
|
+
catch (e) {
|
|
497
|
+
logLine(`startup-migration-failed: ${e?.message ?? String(e)}`);
|
|
498
|
+
}
|
|
499
|
+
let db = null;
|
|
500
|
+
let timer = null;
|
|
501
|
+
let tickInProgress = false;
|
|
502
|
+
const state = new Map();
|
|
503
|
+
const openDb = () => {
|
|
504
|
+
if (!db)
|
|
505
|
+
db = new better_sqlite3_1.default(DB_PATH, { readonly: true });
|
|
506
|
+
return db;
|
|
507
|
+
};
|
|
508
|
+
// Separate WRITABLE handle, opened lazily, used only to consume an agent's
|
|
509
|
+
// /compact consent after firing (reset safe_to_clear→0, clear the anchor) so
|
|
510
|
+
// one yes maps to one compact. The main handle stays readonly by design; WAL
|
|
511
|
+
// tolerates a concurrent reader + this writer.
|
|
512
|
+
let writeDb = null;
|
|
513
|
+
const consumeConsent = (agentId) => {
|
|
514
|
+
try {
|
|
515
|
+
if (!writeDb) {
|
|
516
|
+
writeDb = new better_sqlite3_1.default(DB_PATH);
|
|
517
|
+
// Match NexusDB: tolerate brief contention with the MCP servers that
|
|
518
|
+
// also write this DB (register / setCheckpoint / messages).
|
|
519
|
+
writeDb.pragma("busy_timeout = 5000");
|
|
520
|
+
}
|
|
521
|
+
writeDb
|
|
522
|
+
.prepare(`UPDATE agents SET checkpoint_safe_to_clear = 0, checkpoint_consent_ts = NULL WHERE id = ?`)
|
|
523
|
+
.run(agentId);
|
|
524
|
+
}
|
|
525
|
+
catch (e) {
|
|
526
|
+
logLine(`consume-consent-failed ${agentId}: ${e?.message ?? String(e)}`);
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
// Clear a pending manual override after firing or expiring it, on the same
|
|
530
|
+
// writable handle. One click → one action; the column must not survive a fire.
|
|
531
|
+
const clearManualAction = (agentId) => {
|
|
532
|
+
try {
|
|
533
|
+
if (!writeDb) {
|
|
534
|
+
writeDb = new better_sqlite3_1.default(DB_PATH);
|
|
535
|
+
writeDb.pragma("busy_timeout = 5000");
|
|
536
|
+
}
|
|
537
|
+
writeDb
|
|
538
|
+
.prepare(`UPDATE agents SET manual_action = NULL, manual_action_ts = NULL WHERE id = ?`)
|
|
539
|
+
.run(agentId);
|
|
540
|
+
}
|
|
541
|
+
catch (e) {
|
|
542
|
+
logLine(`clear-manual-failed ${agentId}: ${e?.message ?? String(e)}`);
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
const liveAgents = () => {
|
|
546
|
+
const rows = openDb()
|
|
547
|
+
.prepare(`SELECT id, role, pid, tty, terminal_app, pane_id, session_id, transcript_path,
|
|
548
|
+
checkpoint_ts, checkpoint_safe_to_clear, checkpoint_handoff_path,
|
|
549
|
+
checkpoint_consent_ts, manual_action, manual_action_ts
|
|
550
|
+
FROM agents`)
|
|
551
|
+
.all();
|
|
552
|
+
return rows.filter((r) => isProcessAlive(r.pid));
|
|
553
|
+
};
|
|
554
|
+
const tick = async () => {
|
|
555
|
+
const summary = { checked: 0, flagged: 0, wouldNudge: 0, injected: 0 };
|
|
556
|
+
if (tickInProgress) {
|
|
557
|
+
logLine("skip-tick: previous tick still in flight");
|
|
558
|
+
return summary;
|
|
559
|
+
}
|
|
560
|
+
tickInProgress = true;
|
|
561
|
+
try {
|
|
562
|
+
const s = (0, context_watcher_settings_js_1.readContextWatcherSettings)();
|
|
563
|
+
if (!s.enabled)
|
|
564
|
+
return summary;
|
|
565
|
+
const now = Date.now();
|
|
566
|
+
const cooldownMs = s.cooldownMin * 60 * 1000;
|
|
567
|
+
const live = liveAgents();
|
|
568
|
+
// ── MANUAL OVERRIDES ──────────────────────────────────────────────────
|
|
569
|
+
// Founder-clicked Compact/Clear from the Command Center, handled in their
|
|
570
|
+
// own pass — fully independent of the occupancy / economics / allowlist /
|
|
571
|
+
// mode machinery below. A click is explicit per-terminal authorization, so
|
|
572
|
+
// those gates are bypassed; only the SAFETY gates remain (idle, a GENUINE
|
|
573
|
+
// consent that POSTDATES the click, a verified handoff for /clear, and no
|
|
574
|
+
// ambiguous session at FIRE time) plus a TTL so a forgotten click never
|
|
575
|
+
// fires later. Ambiguity does NOT block the ASK — a parked terminal reads
|
|
576
|
+
// ambiguous, and the ask (typed by exact tty) is what wakes it.
|
|
577
|
+
// Same ask + keystroke path as the autonomous handshake.
|
|
578
|
+
for (const a of live) {
|
|
579
|
+
const manual = a.manual_action;
|
|
580
|
+
if (manual !== "compact" && manual !== "clear")
|
|
581
|
+
continue;
|
|
582
|
+
const ckMin = a.checkpoint_ts != null ? (now - a.checkpoint_ts) / 60000 : null;
|
|
583
|
+
const requestedTs = a.manual_action_ts ?? now;
|
|
584
|
+
const ageMs = now - requestedTs;
|
|
585
|
+
const st = state.get(a.id) || { lastNudgedAt: 0, nudgeCount: 0, lastAskedAt: 0 };
|
|
586
|
+
const session = resolveSession(a);
|
|
587
|
+
if (!session) {
|
|
588
|
+
// Can't target it (no live transcript) — keep pending until the TTL
|
|
589
|
+
// mops it up, but log loudly so a click that "did nothing" is visible.
|
|
590
|
+
if (ageMs > MANUAL_TTL_MS) {
|
|
591
|
+
clearManualAction(a.id);
|
|
592
|
+
logLine(`manual-expired ${a.role} action=${manual} (no session)`);
|
|
593
|
+
}
|
|
594
|
+
logGateStatus({
|
|
595
|
+
role: a.role,
|
|
596
|
+
agent_id: a.id,
|
|
597
|
+
mode: s.mode,
|
|
598
|
+
decision: "manual-skip-no-session",
|
|
599
|
+
reason: `${manual}: no live session to target (kept until ttl)`,
|
|
600
|
+
signal_age_min: ckMin,
|
|
601
|
+
safe_to_clear: a.checkpoint_safe_to_clear === 1 ? 1 : 0,
|
|
602
|
+
});
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
const ag = await armGate(session.file, a.checkpoint_ts);
|
|
606
|
+
const handoffOk = !!a.checkpoint_handoff_path &&
|
|
607
|
+
(0, compact_executor_js_1.verifyHandoff)(a.checkpoint_handoff_path).ok;
|
|
608
|
+
// Relaxed idle for the founder-authorized override: strict idle, OR a
|
|
609
|
+
// terminal parked after a local command (latest=user, quiet > the parked
|
|
610
|
+
// threshold). Safe — the ASK only queues into the input buffer, and FIRE
|
|
611
|
+
// still needs a POSTDATED genuine consent, which requires the agent to
|
|
612
|
+
// have produced an assistant turn (the signal_checkpoint call) first.
|
|
613
|
+
const parkedAfterUser = ag.latestType === "user" && ag.ageSec >= MANUAL_PARKED_SEC;
|
|
614
|
+
const idleEnough = ag.idle || parkedAfterUser;
|
|
615
|
+
const action = (0, manual_decision_js_1.decideManualAction)({
|
|
616
|
+
requested: manual,
|
|
617
|
+
idle: idleEnough,
|
|
618
|
+
ambiguous: session.ambiguous,
|
|
619
|
+
safeToClear: a.checkpoint_safe_to_clear === 1,
|
|
620
|
+
consentTs: a.checkpoint_consent_ts,
|
|
621
|
+
requestedTs,
|
|
622
|
+
handoffOk,
|
|
623
|
+
// The watcher owns the ask, so lastAskedAt is the only ask clock —
|
|
624
|
+
// requestedTs must NOT be reused here or the first prompt waits a full
|
|
625
|
+
// cooldown after the click.
|
|
626
|
+
msSinceLastAsk: st.lastAskedAt > 0 ? now - st.lastAskedAt : null,
|
|
627
|
+
askCooldownMs: cooldownMs,
|
|
628
|
+
ageMs,
|
|
629
|
+
ttlMs: MANUAL_TTL_MS,
|
|
630
|
+
});
|
|
631
|
+
const candidate = {
|
|
632
|
+
id: a.id,
|
|
633
|
+
role: a.role,
|
|
634
|
+
tty: a.tty || "",
|
|
635
|
+
terminal_app: a.terminal_app,
|
|
636
|
+
pane_id: a.pane_id,
|
|
637
|
+
pid: a.pid,
|
|
638
|
+
};
|
|
639
|
+
const detail = parkedAfterUser ? `${ag.detail} parked=true` : ag.detail;
|
|
640
|
+
const mFields = {
|
|
641
|
+
role: a.role,
|
|
642
|
+
agent_id: a.id,
|
|
643
|
+
mode: s.mode,
|
|
644
|
+
idle: idleEnough,
|
|
645
|
+
ambiguous: session.ambiguous,
|
|
646
|
+
handoff_ok: handoffOk,
|
|
647
|
+
signal_age_min: ckMin,
|
|
648
|
+
signal_age_turns: ag.turnsSinceSignal,
|
|
649
|
+
safe_to_clear: a.checkpoint_safe_to_clear === 1 ? 1 : 0,
|
|
650
|
+
};
|
|
651
|
+
if (action.kind === "skip") {
|
|
652
|
+
if (action.reason === "expired") {
|
|
653
|
+
clearManualAction(a.id);
|
|
654
|
+
logLine(`manual-expired ${a.role} action=${manual}`);
|
|
655
|
+
}
|
|
656
|
+
logGateStatus({
|
|
657
|
+
...mFields,
|
|
658
|
+
decision: "manual-skip",
|
|
659
|
+
reason: `${manual}: ${action.reason} (${detail})`,
|
|
660
|
+
});
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
if (action.kind === "ask") {
|
|
664
|
+
const askMsg = manual === "clear" ? MANUAL_CLEAR_ASK : s.askMessage;
|
|
665
|
+
const result = (0, scheduler_js_1.injectKeystroke)(candidate, askMsg);
|
|
666
|
+
// Arm the re-ask cooldown only when the prompt actually landed (ok), or
|
|
667
|
+
// can never land (skip = unsupported terminal — permanent, don't retry
|
|
668
|
+
// every tick). A "fail" is a TRANSIENT iTerm2 timeout (osascript queued
|
|
669
|
+
// behind TUI rendering); leave lastAskedAt untouched so the NEXT tick
|
|
670
|
+
// (~30s) retries instead of stalling a full cooldown — bounded by the
|
|
671
|
+
// manual TTL. Without this, one timed-out inject silently parks the
|
|
672
|
+
// terminal for the whole cooldown.
|
|
673
|
+
if (result !== "fail")
|
|
674
|
+
st.lastAskedAt = now;
|
|
675
|
+
state.set(a.id, st);
|
|
676
|
+
logGateStatus({
|
|
677
|
+
...mFields,
|
|
678
|
+
decision: "manual-ask-consent",
|
|
679
|
+
reason: `${manual}: asked for consent result=${result}`,
|
|
680
|
+
});
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
// action.kind === "fire" — postdated consent + idle (+ verified handoff
|
|
684
|
+
// for /clear). CONSUME consent and CLEAR the request only on a successful
|
|
685
|
+
// keystroke, so one yes maps to one action and a failed inject is retried
|
|
686
|
+
// next tick instead of silently dropped.
|
|
687
|
+
const result = (0, scheduler_js_1.injectKeystroke)(candidate, action.command);
|
|
688
|
+
if (result === "ok") {
|
|
689
|
+
consumeConsent(a.id);
|
|
690
|
+
clearManualAction(a.id);
|
|
691
|
+
summary.injected++;
|
|
692
|
+
}
|
|
693
|
+
logGateStatus({
|
|
694
|
+
...mFields,
|
|
695
|
+
decision: "manual-FIRE",
|
|
696
|
+
reason: `action=${action.command} result=${result} ` +
|
|
697
|
+
(result === "ok"
|
|
698
|
+
? "consumed=consent cleared=manual"
|
|
699
|
+
: "(kept pending — retry next tick)"),
|
|
700
|
+
});
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
for (const a of live) {
|
|
704
|
+
if (a.manual_action === "compact" || a.manual_action === "clear")
|
|
705
|
+
continue; // owned by the manual-override pass above
|
|
706
|
+
summary.checked++;
|
|
707
|
+
// Common header — every emitted gate-status line carries these.
|
|
708
|
+
const ckMinNum = a.checkpoint_ts != null ? (now - a.checkpoint_ts) / 60000 : null;
|
|
709
|
+
const safetyFresh = a.checkpoint_ts != null && now - a.checkpoint_ts < CHECKPOINT_FRESH_MS;
|
|
710
|
+
const session = resolveSession(a);
|
|
711
|
+
if (!session) {
|
|
712
|
+
logGateStatus({
|
|
713
|
+
role: a.role,
|
|
714
|
+
agent_id: a.id,
|
|
715
|
+
mode: s.mode,
|
|
716
|
+
decision: "skip-no-session",
|
|
717
|
+
reason: "resolveSession returned null (no transcript dir / stale path / no live session)",
|
|
718
|
+
signal_age_min: ckMinNum,
|
|
719
|
+
safe_to_clear: a.checkpoint_safe_to_clear === 1 ? 1 : 0,
|
|
720
|
+
});
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
let econ;
|
|
724
|
+
try {
|
|
725
|
+
econ = await latestTurnEconomics(session.file);
|
|
726
|
+
}
|
|
727
|
+
catch (e) {
|
|
728
|
+
logGateStatus({
|
|
729
|
+
role: a.role,
|
|
730
|
+
agent_id: a.id,
|
|
731
|
+
mode: s.mode,
|
|
732
|
+
decision: "skip-econ-error",
|
|
733
|
+
reason: e?.message ?? String(e),
|
|
734
|
+
ambiguous: session.ambiguous,
|
|
735
|
+
signal_age_min: ckMinNum,
|
|
736
|
+
safe_to_clear: a.checkpoint_safe_to_clear === 1 ? 1 : 0,
|
|
737
|
+
});
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
if (econ === null) {
|
|
741
|
+
logGateStatus({
|
|
742
|
+
role: a.role,
|
|
743
|
+
agent_id: a.id,
|
|
744
|
+
mode: s.mode,
|
|
745
|
+
decision: "skip-no-econ",
|
|
746
|
+
reason: "no real assistant-with-usage turns in transcript",
|
|
747
|
+
ambiguous: session.ambiguous,
|
|
748
|
+
signal_age_min: ckMinNum,
|
|
749
|
+
safe_to_clear: a.checkpoint_safe_to_clear === 1 ? 1 : 0,
|
|
750
|
+
});
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
// PROPORTIONAL OCCUPANCY TRIGGER (Stage 0 replacement for the
|
|
754
|
+
// model-blind dollar gate). Compares current context tokens against
|
|
755
|
+
// this model's window: e.g. 100K input-side tokens on a 200K-window
|
|
756
|
+
// model = 50% occupancy, which is the default arming threshold. The
|
|
757
|
+
// dollar value is still computed for observability but no longer
|
|
758
|
+
// arms by itself.
|
|
759
|
+
const windowTokens = (0, usage_reader_js_1.effectiveContextWindow)(econ.model, econ.contextTokens);
|
|
760
|
+
const occupancyFrac = econ.contextTokens / windowTokens;
|
|
761
|
+
const occupancyPct = occupancyFrac * 100;
|
|
762
|
+
const cacheReadPrice = (0, usage_reader_js_1.cacheReadPricePerMtok)(econ.model);
|
|
763
|
+
const perTurnCostUsd = (econ.contextTokens * cacheReadPrice) / 1000000;
|
|
764
|
+
if (occupancyFrac <= s.contextOccupancyThreshold) {
|
|
765
|
+
logGateStatus({
|
|
766
|
+
role: a.role,
|
|
767
|
+
agent_id: a.id,
|
|
768
|
+
mode: s.mode,
|
|
769
|
+
decision: "below-threshold",
|
|
770
|
+
context: econ.contextTokens,
|
|
771
|
+
occupancy_pct: occupancyPct,
|
|
772
|
+
model: econ.model,
|
|
773
|
+
per_turn_usd: perTurnCostUsd,
|
|
774
|
+
turns_per_hr: econ.turnsPerHour,
|
|
775
|
+
ambiguous: session.ambiguous,
|
|
776
|
+
signal_age_min: ckMinNum,
|
|
777
|
+
safe_to_clear: a.checkpoint_safe_to_clear === 1 ? 1 : 0,
|
|
778
|
+
});
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
summary.flagged++;
|
|
782
|
+
// Per-agent throttle state, keyed by agent_id (NOT role): duplicate
|
|
783
|
+
// live roles must not share one cooldown/ask counter. The cooldown is
|
|
784
|
+
// applied per-branch below — it throttles ASKING (and observe nudges),
|
|
785
|
+
// never the FIRE, which rests on durable DB consent.
|
|
786
|
+
const st = state.get(a.id) || { lastNudgedAt: 0, nudgeCount: 0, lastAskedAt: 0 };
|
|
787
|
+
// ── DECISION (fires): only act when forward savings clearly beat the
|
|
788
|
+
// handshake overhead, AND the session is actively progressing (else a
|
|
789
|
+
// session about to idle pays overhead for nothing), weighting rate-of-burn.
|
|
790
|
+
const activelyProgressing = now - econ.lastTurnTs < s.activeWindowMin * 60 * 1000;
|
|
791
|
+
// Forward exposure horizon: rate-of-burn over the active window (how many
|
|
792
|
+
// more turns we'd expect to re-read this context before a natural stop).
|
|
793
|
+
// Conservative: cap at the turns the active window could hold.
|
|
794
|
+
const horizonTurns = Math.max(1, Math.min((econ.turnsPerHour * s.activeWindowMin) / 60, s.activeWindowMin // hard cap so a runaway rate can't inflate savings
|
|
795
|
+
));
|
|
796
|
+
const savedTokensPerTurn = Math.max(0, econ.contextTokens - s.compactBaselineTokens);
|
|
797
|
+
const forwardSavedTokens = savedTokensPerTurn * horizonTurns;
|
|
798
|
+
const netSavedTokens = forwardSavedTokens - s.handshakeOverheadTokens;
|
|
799
|
+
const netSavedUsd = (netSavedTokens * cacheReadPrice) / 1000000;
|
|
800
|
+
const netPositive = netSavedTokens > 0;
|
|
801
|
+
const economicGreen = activelyProgressing && netPositive;
|
|
802
|
+
// Common fields shared by every remaining decision branch.
|
|
803
|
+
const commonFields = {
|
|
804
|
+
role: a.role,
|
|
805
|
+
agent_id: a.id,
|
|
806
|
+
mode: s.mode,
|
|
807
|
+
context: econ.contextTokens,
|
|
808
|
+
occupancy_pct: occupancyPct,
|
|
809
|
+
model: econ.model,
|
|
810
|
+
per_turn_usd: perTurnCostUsd,
|
|
811
|
+
turns_per_hr: econ.turnsPerHour,
|
|
812
|
+
progressing: activelyProgressing,
|
|
813
|
+
ambiguous: session.ambiguous,
|
|
814
|
+
signal_age_min: ckMinNum,
|
|
815
|
+
safe_to_clear: a.checkpoint_safe_to_clear === 1 ? 1 : 0,
|
|
816
|
+
economic_gate: economicGreen,
|
|
817
|
+
net_saved_usd: netSavedUsd,
|
|
818
|
+
};
|
|
819
|
+
if (s.mode === "observe") {
|
|
820
|
+
// Nudge path: throttle re-nudges, then gate on the session being
|
|
821
|
+
// actively progressing (don't pay handshake overhead on one about to
|
|
822
|
+
// idle) + net-positive.
|
|
823
|
+
if (now - st.lastNudgedAt < cooldownMs) {
|
|
824
|
+
logGateStatus({
|
|
825
|
+
...commonFields,
|
|
826
|
+
decision: "skip-cooldown",
|
|
827
|
+
reason: `nudgedAt=${new Date(st.lastNudgedAt).toISOString()} cooldownMin=${s.cooldownMin}`,
|
|
828
|
+
});
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
if (!(activelyProgressing && netPositive)) {
|
|
832
|
+
logGateStatus({
|
|
833
|
+
...commonFields,
|
|
834
|
+
decision: "observe-hold",
|
|
835
|
+
reason: !activelyProgressing ? "idle" : "savings<overhead",
|
|
836
|
+
});
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
summary.wouldNudge++;
|
|
840
|
+
st.lastNudgedAt = now;
|
|
841
|
+
st.nudgeCount++;
|
|
842
|
+
state.set(a.id, st);
|
|
843
|
+
logGateStatus({
|
|
844
|
+
...commonFields,
|
|
845
|
+
decision: "would-nudge",
|
|
846
|
+
reason: `count=${st.nudgeCount} safety_fresh=${safetyFresh}`,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
// ARMED INJECT (mode "inject"). FAIL-CLOSED ALLOWLIST FIRST: only
|
|
851
|
+
// roles the operator explicitly put on injectAllowlist are ever
|
|
852
|
+
// auto-compacted. An empty/unset list arms NO ONE — this is what makes
|
|
853
|
+
// a standing-on rollout a controlled subset, not fleet-wide. Checked
|
|
854
|
+
// before the transcript scan so non-listed agents are skipped cheaply.
|
|
855
|
+
const allowlisted = s.injectAllowlist.includes(a.role);
|
|
856
|
+
if (!allowlisted) {
|
|
857
|
+
logGateStatus({
|
|
858
|
+
...commonFields,
|
|
859
|
+
decision: "inject-skip-allowlist",
|
|
860
|
+
reason: `allowlist=${JSON.stringify(s.injectAllowlist)}`,
|
|
861
|
+
allowlisted: false,
|
|
862
|
+
});
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
// T1 CONSENT HANDSHAKE. The armed path no longer silently fires
|
|
866
|
+
// /compact on mere turn-boundary freshness (which the Stop hook
|
|
867
|
+
// satisfies every turn — the agent never actually consented). It now:
|
|
868
|
+
// FIRE — only on a GENUINE consent (checkpoint_safe_to_clear===1,
|
|
869
|
+
// set solely by an explicit signal_checkpoint(true) and
|
|
870
|
+
// unforgeable by the per-turn hook touch) that is still fresh
|
|
871
|
+
// (within consentFreshMin) AND the agent is idle right now;
|
|
872
|
+
// ASK — when occupancy is high + idle + economically worth a turn
|
|
873
|
+
// and no fire-eligible consent exists, type askMessage to
|
|
874
|
+
// request that consent (throttled by cooldown).
|
|
875
|
+
// Never act on an ambiguous shared-repo session.
|
|
876
|
+
//
|
|
877
|
+
// handoff_ok is STILL computed + logged for visibility but is NOT a
|
|
878
|
+
// gate: /compact is soft-recoverable (it summarizes in place; the idle
|
|
879
|
+
// gate prevents mid-tool-call interruption), so the ask ENCOURAGES a
|
|
880
|
+
// handoff update without hard-gating on it. handoff_ok stays the
|
|
881
|
+
// stronger gate reserved for the future destructive /clear path.
|
|
882
|
+
const ag = await armGate(session.file, a.checkpoint_ts);
|
|
883
|
+
const handoffOk = !!a.checkpoint_handoff_path && (0, compact_executor_js_1.verifyHandoff)(a.checkpoint_handoff_path).ok;
|
|
884
|
+
const consentFreshMs = s.consentFreshMin * 60 * 1000;
|
|
885
|
+
const consentAgeMs = a.checkpoint_consent_ts != null ? now - a.checkpoint_consent_ts : null;
|
|
886
|
+
const consentFresh = a.checkpoint_safe_to_clear === 1 &&
|
|
887
|
+
consentAgeMs !== null &&
|
|
888
|
+
consentAgeMs < consentFreshMs;
|
|
889
|
+
const action = (0, compact_decision_js_1.decideCompactAction)({
|
|
890
|
+
idle: ag.idle,
|
|
891
|
+
ambiguous: session.ambiguous,
|
|
892
|
+
safeToClear: a.checkpoint_safe_to_clear === 1,
|
|
893
|
+
consentAgeMs,
|
|
894
|
+
consentFreshMs,
|
|
895
|
+
economicGreen,
|
|
896
|
+
msSinceLastAsk: st.lastAskedAt > 0 ? now - st.lastAskedAt : null,
|
|
897
|
+
askCooldownMs: cooldownMs,
|
|
898
|
+
});
|
|
899
|
+
const candidate = {
|
|
900
|
+
id: a.id,
|
|
901
|
+
role: a.role,
|
|
902
|
+
tty: a.tty || "",
|
|
903
|
+
terminal_app: a.terminal_app,
|
|
904
|
+
pane_id: a.pane_id,
|
|
905
|
+
pid: a.pid,
|
|
906
|
+
};
|
|
907
|
+
const injectFields = {
|
|
908
|
+
allowlisted: true,
|
|
909
|
+
idle: ag.idle,
|
|
910
|
+
fresh_consent: consentFresh,
|
|
911
|
+
handoff_ok: handoffOk,
|
|
912
|
+
signal_age_turns: ag.turnsSinceSignal,
|
|
913
|
+
};
|
|
914
|
+
if (action.kind === "skip") {
|
|
915
|
+
logGateStatus({
|
|
916
|
+
...commonFields,
|
|
917
|
+
...injectFields,
|
|
918
|
+
decision: "inject-skip-safety",
|
|
919
|
+
reason: `${action.reason} (${ag.detail})`,
|
|
920
|
+
});
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
if (action.kind === "ask") {
|
|
924
|
+
// Asking is NOT the irreversible action — do NOT latch oneShot here.
|
|
925
|
+
const result = (0, scheduler_js_1.injectKeystroke)(candidate, s.askMessage);
|
|
926
|
+
st.lastAskedAt = now;
|
|
927
|
+
state.set(a.id, st);
|
|
928
|
+
logGateStatus({
|
|
929
|
+
...commonFields,
|
|
930
|
+
...injectFields,
|
|
931
|
+
decision: "inject-ask-consent",
|
|
932
|
+
reason: `asked for consent result=${result}`,
|
|
933
|
+
});
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
// action.kind === "fire" — a genuine, fresh consent + idle now.
|
|
937
|
+
// LATCH FIRST: persist enabled=false BEFORE the irreversible /compact.
|
|
938
|
+
// If the process crashes between inject and latch it must NOT re-fire
|
|
939
|
+
// on restart, so the latch lands first. A failed inject is recoverable
|
|
940
|
+
// (re-enable manually); a double-/compact is not.
|
|
941
|
+
//
|
|
942
|
+
// LATCH FAILURE GUARD: writeContextWatcherSettings can throw on a full
|
|
943
|
+
// disk / permission error. If the latch fails we MUST NOT proceed —
|
|
944
|
+
// skip this agent and log loudly.
|
|
945
|
+
if (s.oneShot) {
|
|
946
|
+
try {
|
|
947
|
+
(0, context_watcher_settings_js_1.writeContextWatcherSettings)({ enabled: false });
|
|
948
|
+
logLine(`ONE-SHOT LATCH set (enabled=false) before firing ${a.role}`);
|
|
949
|
+
}
|
|
950
|
+
catch (e) {
|
|
951
|
+
logGateStatus({
|
|
952
|
+
...commonFields,
|
|
953
|
+
...injectFields,
|
|
954
|
+
decision: "inject-LATCH-FAILED",
|
|
955
|
+
reason: e?.message ?? String(e),
|
|
956
|
+
});
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
const result = (0, scheduler_js_1.injectKeystroke)(candidate, s.message);
|
|
961
|
+
// CONSUME the consent only on a SUCCESSFUL inject: one yes → one
|
|
962
|
+
// compact. Post-/compact the flag would otherwise still read 1 and
|
|
963
|
+
// re-fire next tick (non-oneShot); this also stops a stale yes firing
|
|
964
|
+
// after restart. Gated on "ok" so a failed keystroke (skip/fail)
|
|
965
|
+
// preserves the consent for a legitimate retry instead of silently
|
|
966
|
+
// burning it (oneShot's latch masks this today, but non-oneShot
|
|
967
|
+
// would otherwise drop the compact entirely).
|
|
968
|
+
if (result === "ok")
|
|
969
|
+
consumeConsent(a.id);
|
|
970
|
+
summary.injected++;
|
|
971
|
+
st.lastNudgedAt = now;
|
|
972
|
+
st.nudgeCount++;
|
|
973
|
+
state.set(a.id, st);
|
|
974
|
+
logGateStatus({
|
|
975
|
+
...commonFields,
|
|
976
|
+
...injectFields,
|
|
977
|
+
decision: "inject-ARMED-FIRE",
|
|
978
|
+
reason: `action=${JSON.stringify(s.message)} result=${result} consumed=consent`,
|
|
979
|
+
});
|
|
980
|
+
if (s.oneShot)
|
|
981
|
+
break;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return summary;
|
|
985
|
+
}
|
|
986
|
+
finally {
|
|
987
|
+
tickInProgress = false;
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
const reschedule = () => {
|
|
991
|
+
const s = (0, context_watcher_settings_js_1.readContextWatcherSettings)();
|
|
992
|
+
if (timer) {
|
|
993
|
+
clearInterval(timer);
|
|
994
|
+
timer = null;
|
|
995
|
+
}
|
|
996
|
+
if (!s.enabled) {
|
|
997
|
+
logLine("reschedule: disabled");
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
timer = setInterval(() => {
|
|
1001
|
+
tick().catch((e) => logLine(`tick-uncaught: ${e?.message ?? String(e)}`));
|
|
1002
|
+
}, s.intervalSec * 1000);
|
|
1003
|
+
logLine(`reschedule: enabled mode=${s.mode} intervalSec=${s.intervalSec} ` +
|
|
1004
|
+
`occupancyThreshold=${s.contextOccupancyThreshold} ` +
|
|
1005
|
+
`dollarPerTurn(legacy)=${s.dollarPerTurnThreshold} ` +
|
|
1006
|
+
`cooldownMin=${s.cooldownMin}`);
|
|
1007
|
+
};
|
|
1008
|
+
const stop = () => {
|
|
1009
|
+
if (timer) {
|
|
1010
|
+
clearInterval(timer);
|
|
1011
|
+
timer = null;
|
|
1012
|
+
}
|
|
1013
|
+
if (db) {
|
|
1014
|
+
try {
|
|
1015
|
+
db.close();
|
|
1016
|
+
}
|
|
1017
|
+
catch {
|
|
1018
|
+
/* ignore */
|
|
1019
|
+
}
|
|
1020
|
+
db = null;
|
|
1021
|
+
}
|
|
1022
|
+
if (writeDb) {
|
|
1023
|
+
try {
|
|
1024
|
+
writeDb.close();
|
|
1025
|
+
}
|
|
1026
|
+
catch {
|
|
1027
|
+
/* ignore */
|
|
1028
|
+
}
|
|
1029
|
+
writeDb = null;
|
|
1030
|
+
}
|
|
1031
|
+
};
|
|
1032
|
+
reschedule();
|
|
1033
|
+
return { reschedule, stop, tickNow: tick };
|
|
1034
|
+
}
|
|
1035
|
+
//# sourceMappingURL=context-watcher.js.map
|