@bli-cockpit/cli 0.2.117 → 0.2.121
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/dist/commands/analyze.js +74 -54
- package/dist/commands/brief-rewrite.js +164 -101
- package/dist/commands/brief.js +38 -13
- package/dist/commands/correct.js +38 -21
- package/dist/commands/docs.js +13 -10
- package/dist/commands/editor.js +59 -30
- package/dist/commands/install-receipts.js +106 -91
- package/dist/commands/local-args-tower-admin.js +4 -0
- package/dist/commands/local-args-tower-cal.js +28 -3
- package/dist/commands/local-args-tower-chat.js +55 -28
- package/dist/commands/local-args-tower-docs-msg.js +39 -8
- package/dist/commands/local-args-tower-mail.js +27 -1
- package/dist/commands/local-args-tower-models.js +14 -17
- package/dist/commands/local-args-tower-pages.js +62 -5
- package/dist/commands/local-args-tower-work.js +37 -6
- package/dist/commands/local-help-commands.js +6 -3
- package/dist/commands/local-help.js +1 -1
- package/dist/commands/mcp-stdio-probe.js +92 -73
- package/dist/commands/memory-hook-performance.js +135 -101
- package/dist/commands/memory-install-claude.js +15 -14
- package/dist/commands/memory-install-codex.js +10 -6
- package/dist/commands/memory-install-config.js +5 -4
- package/dist/commands/memory-install-contract.js +56 -10
- package/dist/commands/memory-install-report.js +16 -11
- package/dist/commands/memory-install-skills.js +11 -11
- package/dist/commands/memory-log.js +22 -5
- package/dist/commands/msg.js +11 -5
- package/dist/commands/notes-accounts.js +96 -5
- package/dist/commands/notes.js +10 -3
- package/dist/commands/onboard-setup.js +16 -1
- package/dist/commands/ops-sections.js +89 -0
- package/dist/commands/ops.js +117 -120
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/scout.js +90 -68
- package/dist/commands/session-sync-failures.js +19 -13
- package/dist/commands/session-sync-record.js +53 -52
- package/dist/commands/session-sync-upload.js +15 -11
- package/dist/commands/sessions.js +61 -51
- package/dist/commands/slack.js +90 -61
- package/dist/commands/status.js +53 -41
- package/dist/commands/workbook.js +23 -20
- package/package.json +2 -2
|
@@ -65,6 +65,15 @@ export async function probeMcpTool(request) {
|
|
|
65
65
|
catch (error) {
|
|
66
66
|
return { status: "no_answer", ms: elapsed(), reason: "spawn_failed", said: firstLine(errorText(error)) };
|
|
67
67
|
}
|
|
68
|
+
const replies = listenForMcpReplies(child, timeoutMs);
|
|
69
|
+
try {
|
|
70
|
+
return await callMcpTool(request, replies, elapsed);
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
closeMcpProbe(child);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function listenForMcpReplies(child, timeoutMs) {
|
|
68
77
|
const pending = new Map();
|
|
69
78
|
/**
|
|
70
79
|
* A reply that arrived before anyone asked for it. Nothing in the protocol
|
|
@@ -84,6 +93,17 @@ export async function probeMcpTool(request) {
|
|
|
84
93
|
};
|
|
85
94
|
child.on("error", ((error) => end("spawn_failed", firstLine(errorText(error)))));
|
|
86
95
|
child.on("exit", ((code) => end("server_exited", `the server exited with code ${code ?? "null"} before answering`)));
|
|
96
|
+
readMcpStdout(child, pending, early, end);
|
|
97
|
+
const send = (message) => {
|
|
98
|
+
child.stdin?.write(`${JSON.stringify(message)}\n`);
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
send,
|
|
102
|
+
awaitReply: (id) => awaitMcpReply(id, pending, early, () => ended, endWaiters, end, timeoutMs),
|
|
103
|
+
endedDetail: () => endedDetail(ended),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function readMcpStdout(child, pending, early, end) {
|
|
87
107
|
let buffer = "";
|
|
88
108
|
child.stdout?.on("data", (chunk) => {
|
|
89
109
|
buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
@@ -104,33 +124,32 @@ export async function probeMcpTool(request) {
|
|
|
104
124
|
end("protocol_error", "the server wrote something to stdout that is not JSON-RPC");
|
|
105
125
|
return;
|
|
106
126
|
}
|
|
107
|
-
|
|
108
|
-
const id = typeof message["id"] === "number" ? message["id"] : null;
|
|
109
|
-
if (id === null)
|
|
110
|
-
continue;
|
|
111
|
-
const waiter = pending.get(id);
|
|
112
|
-
if (!waiter) {
|
|
113
|
-
early.set(id, message);
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
pending.delete(id);
|
|
117
|
-
waiter(message);
|
|
127
|
+
deliverMcpReply(parsed, pending, early);
|
|
118
128
|
}
|
|
119
129
|
});
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
130
|
+
}
|
|
131
|
+
function deliverMcpReply(message, pending, early) {
|
|
132
|
+
const id = typeof message["id"] === "number" ? message["id"] : null;
|
|
133
|
+
if (id === null)
|
|
134
|
+
return;
|
|
135
|
+
const waiter = pending.get(id);
|
|
136
|
+
if (!waiter) {
|
|
137
|
+
early.set(id, message);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
pending.delete(id);
|
|
141
|
+
waiter(message);
|
|
142
|
+
}
|
|
143
|
+
function awaitMcpReply(id, pending, early, getEnded, endWaiters, end, timeoutMs) {
|
|
144
|
+
return new Promise((resolve) => {
|
|
124
145
|
const alreadyHere = early.get(id);
|
|
125
146
|
if (alreadyHere) {
|
|
126
147
|
early.delete(id);
|
|
127
148
|
resolve(alreadyHere);
|
|
128
149
|
return;
|
|
129
150
|
}
|
|
130
|
-
if (
|
|
131
|
-
resolve(null);
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
151
|
+
if (getEnded())
|
|
152
|
+
return resolve(null);
|
|
134
153
|
const timer = setTimeout(() => {
|
|
135
154
|
pending.delete(id);
|
|
136
155
|
end("timed_out", `the server did not answer within ${timeoutMs} ms`);
|
|
@@ -147,62 +166,62 @@ export async function probeMcpTool(request) {
|
|
|
147
166
|
resolve(message);
|
|
148
167
|
});
|
|
149
168
|
});
|
|
169
|
+
}
|
|
170
|
+
async function callMcpTool(request, replies, elapsed) {
|
|
171
|
+
replies.send({
|
|
172
|
+
jsonrpc: "2.0",
|
|
173
|
+
id: 1,
|
|
174
|
+
method: "initialize",
|
|
175
|
+
params: {
|
|
176
|
+
protocolVersion: "2025-06-18",
|
|
177
|
+
capabilities: {},
|
|
178
|
+
clientInfo: { name: "cockpit-doctor", version: "1" },
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
const initialized = await replies.awaitReply(1);
|
|
182
|
+
if (!initialized)
|
|
183
|
+
return { status: "no_answer", ms: elapsed(), ...replies.endedDetail() };
|
|
184
|
+
if (initialized["error"]) {
|
|
185
|
+
return {
|
|
186
|
+
status: "no_answer",
|
|
187
|
+
ms: elapsed(),
|
|
188
|
+
reason: "protocol_error",
|
|
189
|
+
said: firstLine(rpcErrorMessage(initialized)),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
replies.send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
193
|
+
replies.send({
|
|
194
|
+
jsonrpc: "2.0",
|
|
195
|
+
id: 2,
|
|
196
|
+
method: "tools/call",
|
|
197
|
+
params: { name: request.toolName, arguments: request.toolArguments },
|
|
198
|
+
});
|
|
199
|
+
const called = await replies.awaitReply(2);
|
|
200
|
+
if (!called)
|
|
201
|
+
return { status: "no_answer", ms: elapsed(), ...replies.endedDetail() };
|
|
202
|
+
if (called["error"]) {
|
|
203
|
+
return {
|
|
204
|
+
status: "no_answer",
|
|
205
|
+
ms: elapsed(),
|
|
206
|
+
reason: "protocol_error",
|
|
207
|
+
said: firstLine(rpcErrorMessage(called)),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const result = (called["result"] ?? {});
|
|
211
|
+
const said = firstLine((result.content ?? []).find((part) => part.type === "text")?.text ?? "");
|
|
212
|
+
if (result.isError !== true)
|
|
213
|
+
return { status: "answered", ms: elapsed(), said };
|
|
214
|
+
return { status: "refused", ms: elapsed(), reason: refusalReason(said), said };
|
|
215
|
+
}
|
|
216
|
+
function closeMcpProbe(child) {
|
|
150
217
|
try {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
id: 1,
|
|
154
|
-
method: "initialize",
|
|
155
|
-
params: {
|
|
156
|
-
protocolVersion: "2025-06-18",
|
|
157
|
-
capabilities: {},
|
|
158
|
-
clientInfo: { name: "cockpit-doctor", version: "1" },
|
|
159
|
-
},
|
|
160
|
-
});
|
|
161
|
-
const initialized = await awaitReply(1);
|
|
162
|
-
if (!initialized)
|
|
163
|
-
return { status: "no_answer", ms: elapsed(), ...endedDetail(ended) };
|
|
164
|
-
if (initialized["error"]) {
|
|
165
|
-
return {
|
|
166
|
-
status: "no_answer",
|
|
167
|
-
ms: elapsed(),
|
|
168
|
-
reason: "protocol_error",
|
|
169
|
-
said: firstLine(rpcErrorMessage(initialized)),
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
173
|
-
send({
|
|
174
|
-
jsonrpc: "2.0",
|
|
175
|
-
id: 2,
|
|
176
|
-
method: "tools/call",
|
|
177
|
-
params: { name: request.toolName, arguments: request.toolArguments },
|
|
178
|
-
});
|
|
179
|
-
const called = await awaitReply(2);
|
|
180
|
-
if (!called)
|
|
181
|
-
return { status: "no_answer", ms: elapsed(), ...endedDetail(ended) };
|
|
182
|
-
if (called["error"]) {
|
|
183
|
-
return {
|
|
184
|
-
status: "no_answer",
|
|
185
|
-
ms: elapsed(),
|
|
186
|
-
reason: "protocol_error",
|
|
187
|
-
said: firstLine(rpcErrorMessage(called)),
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
const result = (called["result"] ?? {});
|
|
191
|
-
const said = firstLine((result.content ?? []).find((part) => part.type === "text")?.text ?? "");
|
|
192
|
-
if (result.isError !== true)
|
|
193
|
-
return { status: "answered", ms: elapsed(), said };
|
|
194
|
-
return { status: "refused", ms: elapsed(), reason: refusalReason(said), said };
|
|
218
|
+
child.stdin?.end();
|
|
219
|
+
child.kill();
|
|
195
220
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
catch (error) {
|
|
202
|
-
// The verdict is already decided; a child that will not close changes
|
|
203
|
-
// none of it. Not silent: the reason travels in the caller's log.
|
|
204
|
-
void error;
|
|
205
|
-
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
// The verdict is already decided; a child that will not close changes
|
|
223
|
+
// none of it. Not silent: the reason travels in the caller's log.
|
|
224
|
+
void error;
|
|
206
225
|
}
|
|
207
226
|
}
|
|
208
227
|
/**
|
|
@@ -15,130 +15,163 @@ export async function readMemoryHookPerformance(options) {
|
|
|
15
15
|
const directory = memoryHookSamplesDirectory(options.homeDir);
|
|
16
16
|
const start = now.getTime() - 24 * HOUR_MS;
|
|
17
17
|
const reasons = new Set();
|
|
18
|
-
const
|
|
19
|
-
schema_version: "memory-hook-performance.v1",
|
|
20
|
-
window_start: new Date(start).toISOString(),
|
|
21
|
-
window_end: now.toISOString(),
|
|
22
|
-
sampling_since: null,
|
|
23
|
-
samples: 0,
|
|
24
|
-
outcomes: { printed: 0, empty: 0, timeouts: 0, failed: 0, skipped: 0 },
|
|
25
|
-
duration_by_cache: {
|
|
26
|
-
hit: blankHistogram(), miss: blankHistogram(), shared: blankHistogram(), unknown: blankHistogram(),
|
|
27
|
-
},
|
|
28
|
-
producer_versions: [],
|
|
29
|
-
invalid_samples: 0,
|
|
30
|
-
incomplete_samples: 0,
|
|
31
|
-
unreadable_samples: 0,
|
|
32
|
-
capped: false,
|
|
33
|
-
reasons: [],
|
|
34
|
-
};
|
|
35
|
-
const versions = new Map();
|
|
36
|
-
const started = performance.now();
|
|
37
|
-
let filesRead = 0;
|
|
18
|
+
const state = createSamplingState(now, start);
|
|
38
19
|
try {
|
|
39
20
|
const lease = await renewLease(directory, now, reasons);
|
|
40
|
-
result.sampling_since = lease.continuous_since;
|
|
21
|
+
state.result.sampling_since = lease.continuous_since;
|
|
41
22
|
if (Date.parse(lease.continuous_since) > start)
|
|
42
23
|
reasons.add("partial_window");
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
24
|
+
await collectHourlySamples(directory, now, start, options, reasons, state);
|
|
25
|
+
await pruneExpiredHours(directory, now, reasons);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
reasons.add("read_failed");
|
|
29
|
+
}
|
|
30
|
+
return finishSampling(state, reasons);
|
|
31
|
+
}
|
|
32
|
+
function createSamplingState(now, start) {
|
|
33
|
+
return {
|
|
34
|
+
result: {
|
|
35
|
+
schema_version: "memory-hook-performance.v1",
|
|
36
|
+
window_start: new Date(start).toISOString(),
|
|
37
|
+
window_end: now.toISOString(),
|
|
38
|
+
sampling_since: null,
|
|
39
|
+
samples: 0,
|
|
40
|
+
outcomes: { printed: 0, empty: 0, timeouts: 0, failed: 0, skipped: 0 },
|
|
41
|
+
duration_by_cache: {
|
|
42
|
+
hit: blankHistogram(),
|
|
43
|
+
miss: blankHistogram(),
|
|
44
|
+
shared: blankHistogram(),
|
|
45
|
+
unknown: blankHistogram(),
|
|
46
|
+
},
|
|
47
|
+
producer_versions: [],
|
|
48
|
+
invalid_samples: 0,
|
|
49
|
+
incomplete_samples: 0,
|
|
50
|
+
unreadable_samples: 0,
|
|
51
|
+
capped: false,
|
|
52
|
+
reasons: [],
|
|
53
|
+
},
|
|
54
|
+
versions: new Map(),
|
|
55
|
+
started: performance.now(),
|
|
56
|
+
filesRead: 0,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async function collectHourlySamples(directory, now, start, options, reasons, state) {
|
|
60
|
+
// Exactly the 25 hour directories touching this rolling 24-hour window.
|
|
61
|
+
// Timestamp filtering below trims both partial boundary hours.
|
|
62
|
+
hours: for (let offset = 0; offset <= 24; offset += 1) {
|
|
63
|
+
const hour = new Date(now.getTime() - offset * HOUR_MS).toISOString().slice(0, 13);
|
|
64
|
+
const entries = await openHourDirectory(directory, hour, reasons);
|
|
65
|
+
if (!entries)
|
|
66
|
+
continue;
|
|
67
|
+
for await (const entry of entries) {
|
|
68
|
+
if (!entry.isFile() || !SAMPLE_FILE.test(entry.name))
|
|
54
69
|
continue;
|
|
70
|
+
if (hasReachedReadLimit(options, state)) {
|
|
71
|
+
state.result.capped = true;
|
|
72
|
+
reasons.add("read_limit");
|
|
73
|
+
break hours;
|
|
55
74
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
performance.now() - started >= (options.readBudgetMs ?? READ_BUDGET_MS)) {
|
|
61
|
-
result.capped = true;
|
|
62
|
-
reasons.add("read_limit");
|
|
63
|
-
break hours;
|
|
64
|
-
}
|
|
65
|
-
filesRead += 1;
|
|
66
|
-
const file = path.join(directory, hour, entry.name);
|
|
67
|
-
let raw;
|
|
68
|
-
try {
|
|
69
|
-
const stat = await fs.stat(file);
|
|
70
|
-
if (stat.size > MAX_SAMPLE_BYTES) {
|
|
71
|
-
result.invalid_samples += 1;
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
raw = await fs.readFile(file, "utf8");
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
result.unreadable_samples += 1;
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
if (!raw.endsWith("\n")) {
|
|
81
|
-
// Could be a live writer or one killed by the host. Leave it for the
|
|
82
|
-
// next tick, and keep the incomplete observation out of every rate.
|
|
83
|
-
result.incomplete_samples += 1;
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
let parsed;
|
|
87
|
-
try {
|
|
88
|
-
parsed = JSON.parse(raw);
|
|
89
|
-
}
|
|
90
|
-
catch {
|
|
91
|
-
result.invalid_samples += 1;
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
const sample = MemoryHookSampleSchema.safeParse(parsed);
|
|
95
|
-
if (!sample.success || sample.data.recorded_at.slice(0, 13) !== hour) {
|
|
96
|
-
result.invalid_samples += 1;
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
const at = Date.parse(sample.data.recorded_at);
|
|
100
|
-
if (at < start || at > now.getTime())
|
|
101
|
-
continue;
|
|
102
|
-
const version = sample.data.producer_version;
|
|
103
|
-
if (!versions.has(version) && versions.size >= 16) {
|
|
104
|
-
result.capped = true;
|
|
105
|
-
reasons.add("read_limit");
|
|
106
|
-
break hours;
|
|
107
|
-
}
|
|
108
|
-
result.samples += 1;
|
|
109
|
-
result.outcomes[sample.data.outcome] += 1;
|
|
110
|
-
addDuration(result.duration_by_cache[sample.data.embed_cache], sample.data.elapsed_ms);
|
|
111
|
-
versions.set(version, (versions.get(version) ?? 0) + 1);
|
|
75
|
+
state.filesRead += 1;
|
|
76
|
+
const shouldContinue = await readSample(path.join(directory, hour, entry.name), hour, now, start, reasons, state);
|
|
77
|
+
if (!shouldContinue) {
|
|
78
|
+
break hours;
|
|
112
79
|
}
|
|
113
80
|
}
|
|
114
|
-
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function openHourDirectory(directory, hour, reasons) {
|
|
84
|
+
try {
|
|
85
|
+
return await fs.opendir(path.join(directory, hour));
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error.code !== "ENOENT")
|
|
89
|
+
reasons.add("read_failed");
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function hasReachedReadLimit(options, state) {
|
|
94
|
+
return state.filesRead >= (options.maxFiles ?? HOOK_SAMPLE_READ_LIMIT) ||
|
|
95
|
+
performance.now() - state.started >= (options.readBudgetMs ?? READ_BUDGET_MS);
|
|
96
|
+
}
|
|
97
|
+
async function readSample(file, hour, now, start, reasons, state) {
|
|
98
|
+
let raw;
|
|
99
|
+
try {
|
|
100
|
+
const stat = await fs.stat(file);
|
|
101
|
+
if (stat.size > MAX_SAMPLE_BYTES) {
|
|
102
|
+
state.result.invalid_samples += 1;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
raw = await fs.readFile(file, "utf8");
|
|
115
106
|
}
|
|
116
107
|
catch {
|
|
117
|
-
|
|
108
|
+
state.result.unreadable_samples += 1;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
if (!raw.endsWith("\n")) {
|
|
112
|
+
// Could be a live writer or one killed by the host. Leave it for the
|
|
113
|
+
// next tick, and keep the incomplete observation out of every rate.
|
|
114
|
+
state.result.incomplete_samples += 1;
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
return recordSample(raw, hour, now, start, reasons, state);
|
|
118
|
+
}
|
|
119
|
+
function recordSample(raw, hour, now, start, reasons, state) {
|
|
120
|
+
let parsed;
|
|
121
|
+
try {
|
|
122
|
+
parsed = JSON.parse(raw);
|
|
118
123
|
}
|
|
119
|
-
|
|
124
|
+
catch {
|
|
125
|
+
state.result.invalid_samples += 1;
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
const sample = MemoryHookSampleSchema.safeParse(parsed);
|
|
129
|
+
if (!sample.success || sample.data.recorded_at.slice(0, 13) !== hour) {
|
|
130
|
+
state.result.invalid_samples += 1;
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
const at = Date.parse(sample.data.recorded_at);
|
|
134
|
+
if (at < start || at > now.getTime())
|
|
135
|
+
return true;
|
|
136
|
+
const version = sample.data.producer_version;
|
|
137
|
+
if (!state.versions.has(version) && state.versions.size >= 16) {
|
|
138
|
+
state.result.capped = true;
|
|
139
|
+
reasons.add("read_limit");
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
state.result.samples += 1;
|
|
143
|
+
state.result.outcomes[sample.data.outcome] += 1;
|
|
144
|
+
addDuration(state.result.duration_by_cache[sample.data.embed_cache], sample.data.elapsed_ms);
|
|
145
|
+
state.versions.set(version, (state.versions.get(version) ?? 0) + 1);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
function finishSampling(state, reasons) {
|
|
149
|
+
if (!state.result.samples)
|
|
120
150
|
reasons.add("no_samples");
|
|
121
|
-
if (result.invalid_samples)
|
|
151
|
+
if (state.result.invalid_samples)
|
|
122
152
|
reasons.add("invalid_samples");
|
|
123
|
-
if (result.incomplete_samples)
|
|
153
|
+
if (state.result.incomplete_samples)
|
|
124
154
|
reasons.add("incomplete_samples");
|
|
125
|
-
if (result.unreadable_samples)
|
|
155
|
+
if (state.result.unreadable_samples)
|
|
126
156
|
reasons.add("unreadable_samples");
|
|
127
|
-
result.producer_versions = [...versions].sort(([a], [b]) => a.localeCompare(b))
|
|
157
|
+
state.result.producer_versions = [...state.versions].sort(([a], [b]) => a.localeCompare(b))
|
|
128
158
|
.map(([version, samples]) => ({ version, samples }));
|
|
129
|
-
result.reasons = [...reasons];
|
|
130
|
-
return result;
|
|
159
|
+
state.result.reasons = [...reasons];
|
|
160
|
+
return state.result;
|
|
131
161
|
}
|
|
132
162
|
async function renewLease(directory, now, reasons) {
|
|
133
163
|
let continuousSince = now.toISOString();
|
|
134
164
|
try {
|
|
135
165
|
const previous = MemoryHookSamplingLeaseSchema.safeParse(JSON.parse(await fs.readFile(path.join(directory, "lease.json"), "utf8")));
|
|
136
|
-
if (!previous.success || Date.parse(previous.data.continuous_since) > now.getTime())
|
|
166
|
+
if (!previous.success || Date.parse(previous.data.continuous_since) > now.getTime()) {
|
|
137
167
|
reasons.add("lease_invalid");
|
|
138
|
-
|
|
168
|
+
}
|
|
169
|
+
else if (Date.parse(previous.data.expires_at) <= now.getTime()) {
|
|
139
170
|
reasons.add("lease_expired");
|
|
140
|
-
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
141
173
|
continuousSince = previous.data.continuous_since;
|
|
174
|
+
}
|
|
142
175
|
}
|
|
143
176
|
catch (error) {
|
|
144
177
|
reasons.add(error.code === "ENOENT" ? "lease_absent" : "lease_invalid");
|
|
@@ -160,8 +193,9 @@ async function pruneExpiredHours(directory, now, reasons) {
|
|
|
160
193
|
const entries = await fs.opendir(directory);
|
|
161
194
|
let pruned = 0;
|
|
162
195
|
for await (const entry of entries) {
|
|
163
|
-
if (!entry.isDirectory() || !HOUR_DIRECTORY.test(entry.name) || entry.name >= oldest)
|
|
196
|
+
if (!entry.isDirectory() || !HOUR_DIRECTORY.test(entry.name) || entry.name >= oldest) {
|
|
164
197
|
continue;
|
|
198
|
+
}
|
|
165
199
|
// Cleanup belongs to the collector and cannot delay a prompt. A bounded
|
|
166
200
|
// number of expired hours is enough to catch up over successive ticks.
|
|
167
201
|
await fs.rm(path.join(directory, entry.name), { recursive: true, force: true });
|
|
@@ -84,21 +84,11 @@ export async function inspectClaudeMemoryIntegration(options) {
|
|
|
84
84
|
* `matches` pair runs differs, both already parameters here.
|
|
85
85
|
*/
|
|
86
86
|
export async function applyJsonTarget(input) {
|
|
87
|
+
const prepared = await readJsonTargetRoot(input);
|
|
88
|
+
if ("target" in prepared)
|
|
89
|
+
return prepared;
|
|
87
90
|
const { file, options } = input;
|
|
88
|
-
const raw =
|
|
89
|
-
let root;
|
|
90
|
-
if (raw === null || !raw.trim()) {
|
|
91
|
-
root = {};
|
|
92
|
-
}
|
|
93
|
-
else {
|
|
94
|
-
const parsed = parseJsonRecord(raw);
|
|
95
|
-
if (!parsed) {
|
|
96
|
-
// Refuse rather than replace. A `~/.claude.json` that will not parse
|
|
97
|
-
// still holds the person's project history and their other servers.
|
|
98
|
-
return failure(input.target, file, "config_unreadable", "the file is not valid JSON; fix it and re-run");
|
|
99
|
-
}
|
|
100
|
-
root = parsed;
|
|
101
|
-
}
|
|
91
|
+
const { raw, root } = prepared;
|
|
102
92
|
if (input.matches(root)) {
|
|
103
93
|
return { target: input.target, status: "already", reason: "already_current", path: file };
|
|
104
94
|
}
|
|
@@ -126,6 +116,17 @@ export async function applyJsonTarget(input) {
|
|
|
126
116
|
}
|
|
127
117
|
return { target: input.target, status: "installed", reason: "wrote_entry", path: file };
|
|
128
118
|
}
|
|
119
|
+
async function readJsonTargetRoot(input) {
|
|
120
|
+
const raw = await input.options.io.readText(input.file);
|
|
121
|
+
if (raw === null || !raw.trim())
|
|
122
|
+
return { raw, root: {} };
|
|
123
|
+
const root = parseJsonRecord(raw);
|
|
124
|
+
if (root)
|
|
125
|
+
return { raw, root };
|
|
126
|
+
// Refuse rather than replace. A `~/.claude.json` that will not parse
|
|
127
|
+
// still holds the person's project history and their other servers.
|
|
128
|
+
return failure(input.target, input.file, "config_unreadable", "the file is not valid JSON; fix it and re-run");
|
|
129
|
+
}
|
|
129
130
|
/** Exported for `tower-mcp-claude.ts` (BLI-3706) — see `applyJsonTarget`'s note. */
|
|
130
131
|
export async function inspectJsonTarget(input) {
|
|
131
132
|
const raw = await input.io.readText(input.file);
|
|
@@ -123,12 +123,7 @@ async function applyCodexMcpTable(options) {
|
|
|
123
123
|
async function applyCodexSkills(options) {
|
|
124
124
|
const directory = codexSkillDirectory(options.homeDir);
|
|
125
125
|
const files = memoryCodexSkillFiles();
|
|
126
|
-
const stale =
|
|
127
|
-
for (const [relative, contents] of Object.entries(files)) {
|
|
128
|
-
const stored = await options.io.readText(path.join(directory, relative));
|
|
129
|
-
if (stored !== contents)
|
|
130
|
-
stale.push(relative);
|
|
131
|
-
}
|
|
126
|
+
const stale = await findStaleCodexSkillFiles(directory, files, options.io);
|
|
132
127
|
if (stale.length === 0) {
|
|
133
128
|
return {
|
|
134
129
|
target: "codex_skills",
|
|
@@ -181,6 +176,15 @@ async function applyCodexSkills(options) {
|
|
|
181
176
|
detail: `${stale.length} skill file(s) written`,
|
|
182
177
|
};
|
|
183
178
|
}
|
|
179
|
+
async function findStaleCodexSkillFiles(directory, files, io) {
|
|
180
|
+
const stale = [];
|
|
181
|
+
for (const [relative, contents] of Object.entries(files)) {
|
|
182
|
+
const stored = await io.readText(path.join(directory, relative));
|
|
183
|
+
if (stored !== contents)
|
|
184
|
+
stale.push(relative);
|
|
185
|
+
}
|
|
186
|
+
return stale;
|
|
187
|
+
}
|
|
184
188
|
export function renderMemoryTable(config) {
|
|
185
189
|
const entries = [
|
|
186
190
|
["command", config.mcp_server.command],
|
|
@@ -51,9 +51,10 @@ export async function resolveMemoryConfig(command, io, platform, deps) {
|
|
|
51
51
|
}
|
|
52
52
|
if (isUnsafeBinPath(found.path)) {
|
|
53
53
|
// A hook command is a shell string by the platform's design. A path that
|
|
54
|
-
// cannot be
|
|
55
|
-
// for a bare name that may resolve to something else either — the
|
|
56
|
-
// refuses and says why.
|
|
54
|
+
// cannot be expressed safely in one is not escaped cleverly, and it is not
|
|
55
|
+
// swapped for a bare name that may resolve to something else either — the
|
|
56
|
+
// install refuses and says why. See `shellSafeBinPath` for what CAN be
|
|
57
|
+
// expressed, including the Windows separator normalisation of BLI-4136.
|
|
57
58
|
return {
|
|
58
59
|
config: null,
|
|
59
60
|
source: "none",
|
|
@@ -61,7 +62,7 @@ export async function resolveMemoryConfig(command, io, platform, deps) {
|
|
|
61
62
|
target: "bin",
|
|
62
63
|
status: "failed",
|
|
63
64
|
reason: "bin_path_unsafe",
|
|
64
|
-
detail: "the resolved bin path
|
|
65
|
+
detail: "the resolved bin path cannot be expressed safely in a hook command; nothing was written",
|
|
65
66
|
},
|
|
66
67
|
bin_source: found.source,
|
|
67
68
|
};
|