@blastin-dev/clocktopus-cli 0.2.0 → 0.2.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 +89 -34
- package/dist/src/commands/agent/disable.d.ts +8 -1
- package/dist/src/commands/agent/disable.d.ts.map +1 -1
- package/dist/src/commands/agent/disable.js +79 -45
- package/dist/src/commands/agent/doctor.d.ts.map +1 -1
- package/dist/src/commands/agent/doctor.js +146 -75
- package/dist/src/commands/agent/hook.d.ts +4 -1
- package/dist/src/commands/agent/hook.d.ts.map +1 -1
- package/dist/src/commands/agent/hook.js +152 -15
- package/dist/src/commands/agent/setup.d.ts +17 -10
- package/dist/src/commands/agent/setup.d.ts.map +1 -1
- package/dist/src/commands/agent/setup.js +208 -69
- package/dist/src/commands/agent/status.d.ts.map +1 -1
- package/dist/src/commands/agent/status.js +46 -24
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +18 -4
- package/dist/src/lib/agent-config.d.ts +18 -3
- package/dist/src/lib/agent-config.d.ts.map +1 -1
- package/dist/src/lib/agent-config.js +44 -19
- package/dist/src/lib/agent-hook-state.d.ts +9 -1
- package/dist/src/lib/agent-hook-state.d.ts.map +1 -1
- package/dist/src/lib/agent-hook-state.js +21 -2
- package/dist/src/lib/agents.d.ts +115 -0
- package/dist/src/lib/agents.d.ts.map +1 -0
- package/dist/src/lib/agents.js +245 -0
- package/dist/src/lib/codex-config.d.ts +166 -0
- package/dist/src/lib/codex-config.d.ts.map +1 -0
- package/dist/src/lib/codex-config.js +441 -0
- package/dist/src/lib/codex-config.test.d.ts +2 -0
- package/dist/src/lib/codex-config.test.d.ts.map +1 -0
- package/dist/src/lib/codex-config.test.js +359 -0
- package/dist/src/lib/opencode-config.d.ts +108 -0
- package/dist/src/lib/opencode-config.d.ts.map +1 -0
- package/dist/src/lib/opencode-config.js +330 -0
- package/dist/src/lib/opencode-config.test.d.ts +2 -0
- package/dist/src/lib/opencode-config.test.d.ts.map +1 -0
- package/dist/src/lib/opencode-config.test.js +140 -0
- package/package.json +2 -1
|
@@ -3,81 +3,143 @@ import { isAfter, parseISO } from "date-fns";
|
|
|
3
3
|
import { findShadowedExports, maskToken, resolveAgentCredentials, } from "../../lib/agent-config.js";
|
|
4
4
|
import { readLastRun } from "../../lib/agent-hook-state.js";
|
|
5
5
|
import { verifyReceiver } from "../../lib/agent-receiver.js";
|
|
6
|
-
import {
|
|
6
|
+
import { isConfigParseError, surveyAgents } from "../../lib/agents.js";
|
|
7
7
|
import { isLoggedIn } from "../../lib/config.js";
|
|
8
8
|
import { formatAgo } from "../../lib/format.js";
|
|
9
9
|
import { fetchRepoStatus, repoChecks } from "../../lib/repo-guidance.js";
|
|
10
10
|
export async function doctorCommand() {
|
|
11
|
+
const sections = [];
|
|
12
|
+
sections.push({
|
|
13
|
+
title: "This machine",
|
|
14
|
+
checks: [
|
|
15
|
+
{
|
|
16
|
+
label: "Logged in",
|
|
17
|
+
ok: isLoggedIn(),
|
|
18
|
+
detail: isLoggedIn() ? "yes" : "no",
|
|
19
|
+
fix: "clocktopus login",
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
const survey = surveyAgents();
|
|
24
|
+
const relevant = survey.filter((entry) => entry.installed || entry.configured || entry.unreadable);
|
|
25
|
+
if (relevant.length === 0) {
|
|
26
|
+
sections[0].checks.push({
|
|
27
|
+
label: "Agents",
|
|
28
|
+
ok: false,
|
|
29
|
+
detail: `none of ${survey.map((e) => e.agent.binary).join(", ")} found on PATH`,
|
|
30
|
+
fix: "Install a supported agent, then run 'clocktopus agent setup'",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
for (const entry of relevant) {
|
|
34
|
+
sections.push({
|
|
35
|
+
title: `${entry.agent.label}${entry.version ? ` ${entry.version}` : ""}`,
|
|
36
|
+
checks: await agentChecks(entry),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
// Past this point the pipeline works; these decide whether what it carries
|
|
40
|
+
// can be turned into a number. Both are server-side facts about the
|
|
41
|
+
// repository, not about any one agent.
|
|
42
|
+
const tail = [];
|
|
43
|
+
const repoStatus = await fetchRepoStatus();
|
|
44
|
+
if (repoStatus)
|
|
45
|
+
tail.push(...repoChecks(repoStatus));
|
|
46
|
+
tail.push(checkLastHookRun());
|
|
47
|
+
sections.push({ title: "Repository and delivery", checks: tail });
|
|
48
|
+
report(sections);
|
|
49
|
+
}
|
|
50
|
+
async function agentChecks(entry) {
|
|
11
51
|
const checks = [];
|
|
52
|
+
if (entry.unreadable) {
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
label: "Config",
|
|
56
|
+
ok: false,
|
|
57
|
+
detail: "could not be parsed",
|
|
58
|
+
fix: entry.unreadable,
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
}
|
|
12
62
|
checks.push({
|
|
13
|
-
label: "
|
|
14
|
-
ok:
|
|
15
|
-
detail:
|
|
16
|
-
|
|
63
|
+
label: "Installed",
|
|
64
|
+
ok: entry.installed ? true : "warn",
|
|
65
|
+
detail: entry.installed
|
|
66
|
+
? `${entry.agent.binary} ${entry.version ?? ""}`.trim()
|
|
67
|
+
: `${entry.agent.binary} is not on PATH`,
|
|
68
|
+
fix: "Config here is harmless, but nothing will report until it is installed",
|
|
17
69
|
});
|
|
18
|
-
let
|
|
70
|
+
let state;
|
|
19
71
|
try {
|
|
20
|
-
|
|
21
|
-
checks.push({
|
|
22
|
-
label: "settings.json",
|
|
23
|
-
ok: installed.exists,
|
|
24
|
-
detail: installed.exists
|
|
25
|
-
? `${installed.path} (modified ${formatAgo(installed.modifiedAt?.toISOString() ?? null)})`
|
|
26
|
-
: `${installed.path} does not exist`,
|
|
27
|
-
fix: "clocktopus agent setup",
|
|
28
|
-
});
|
|
72
|
+
state = entry.agent.read();
|
|
29
73
|
}
|
|
30
74
|
catch (error) {
|
|
31
|
-
if (error
|
|
32
|
-
|
|
33
|
-
|
|
75
|
+
if (!isConfigParseError(error))
|
|
76
|
+
throw error;
|
|
77
|
+
return [
|
|
78
|
+
{
|
|
79
|
+
label: "Config",
|
|
34
80
|
ok: false,
|
|
35
|
-
detail: "
|
|
36
|
-
fix:
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
throw error;
|
|
81
|
+
detail: "could not be parsed",
|
|
82
|
+
fix: error.message,
|
|
83
|
+
},
|
|
84
|
+
];
|
|
42
85
|
}
|
|
43
|
-
const missingEnv = TELEMETRY_ENV_KEYS.filter((key) => !installed?.env[key]);
|
|
44
86
|
checks.push({
|
|
45
|
-
label: "
|
|
46
|
-
ok:
|
|
47
|
-
detail:
|
|
48
|
-
?
|
|
49
|
-
:
|
|
50
|
-
|
|
51
|
-
missingEnv.length === TELEMETRY_ENV_KEYS.length
|
|
52
|
-
? "not configured in settings.json"
|
|
53
|
-
: `missing ${missingEnv.join(", ")}`,
|
|
54
|
-
fix: "clocktopus agent setup",
|
|
87
|
+
label: "Config",
|
|
88
|
+
ok: state.token !== null,
|
|
89
|
+
detail: state.token !== null
|
|
90
|
+
? `${state.paths.join(", ")} (modified ${formatAgo(state.modifiedAt?.toISOString() ?? null)})`
|
|
91
|
+
: `not configured in ${state.paths.join(", ")}`,
|
|
92
|
+
fix: `clocktopus agent setup --agent ${entry.agent.id}`,
|
|
55
93
|
});
|
|
56
94
|
// Both events are required, and for different reasons: SessionStart is the
|
|
57
95
|
// only source of the *before* SHA and of `cwd`, SessionEnd is the only
|
|
58
96
|
// source of the exact commit list. One without the other silently
|
|
59
97
|
// degrades what can be attributed.
|
|
60
|
-
const hookStart =
|
|
61
|
-
const hookEnd =
|
|
98
|
+
const hookStart = state.hookCommands.SessionStart;
|
|
99
|
+
const hookEnd = state.hookCommands.SessionEnd;
|
|
62
100
|
checks.push({
|
|
63
101
|
label: "Hooks installed",
|
|
64
102
|
ok: Boolean(hookStart && hookEnd),
|
|
65
103
|
detail: hookStart && hookEnd
|
|
66
104
|
? `SessionStart, SessionEnd → ${hookStart}`
|
|
67
|
-
: `missing ${[!hookStart && "SessionStart", !hookEnd && "SessionEnd"]
|
|
68
|
-
|
|
105
|
+
: `missing ${[!hookStart && "SessionStart", !hookEnd && "SessionEnd"]
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
.join(", ")}`,
|
|
108
|
+
fix: `clocktopus agent setup --agent ${entry.agent.id}`,
|
|
69
109
|
});
|
|
70
|
-
if (hookStart)
|
|
110
|
+
if (hookStart)
|
|
71
111
|
checks.push(checkHookRuns(hookStart));
|
|
112
|
+
// A warning, not a failure: a plugin from an older generation still
|
|
113
|
+
// reports. What it does not have is whatever the newer one fixed, and
|
|
114
|
+
// nothing else in this report would ever say so — the token is valid, the
|
|
115
|
+
// hook resolves, the receiver answers.
|
|
116
|
+
const stale = entry.agent.staleReason?.() ?? null;
|
|
117
|
+
if (stale) {
|
|
118
|
+
checks.push({
|
|
119
|
+
label: "Generated config",
|
|
120
|
+
ok: "warn",
|
|
121
|
+
detail: stale,
|
|
122
|
+
fix: `clocktopus agent setup --agent ${entry.agent.id}`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
for (const action of entry.agent.pendingActions()) {
|
|
126
|
+
checks.push({
|
|
127
|
+
label: "Hooks enabled",
|
|
128
|
+
ok: "warn",
|
|
129
|
+
detail: "installed but not yet approved by the agent",
|
|
130
|
+
fix: action,
|
|
131
|
+
});
|
|
72
132
|
}
|
|
73
|
-
const credentials = resolveAgentCredentials();
|
|
133
|
+
const credentials = resolveAgentCredentials(entry.agent.id);
|
|
74
134
|
checks.push({
|
|
75
135
|
label: "Token in force",
|
|
76
136
|
ok: credentials.token !== null,
|
|
77
137
|
detail: credentials.token
|
|
78
|
-
? `${maskToken(credentials.token)} (from ${credentials.tokenSource
|
|
138
|
+
? `${maskToken(credentials.token)} (from ${credentials.tokenSource === "settings"
|
|
139
|
+
? credentials.sourcePath
|
|
140
|
+
: credentials.tokenSource})`
|
|
79
141
|
: "none resolved",
|
|
80
|
-
fix:
|
|
142
|
+
fix: `clocktopus agent setup --agent ${entry.agent.id}`,
|
|
81
143
|
});
|
|
82
144
|
if (credentials.token && credentials.endpoint) {
|
|
83
145
|
const check = await verifyReceiver(credentials.endpoint, credentials.token);
|
|
@@ -96,23 +158,19 @@ export async function doctorCommand() {
|
|
|
96
158
|
: "clocktopus agent setup --force (mints a replacement token)",
|
|
97
159
|
});
|
|
98
160
|
}
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
checks.push(checkShadowedExports());
|
|
106
|
-
checks.push(checkRestartNeeded(installed.modifiedAt));
|
|
107
|
-
checks.push(checkLastHookRun());
|
|
108
|
-
report(checks);
|
|
161
|
+
// Only Claude Code applies config `env` over the inherited environment,
|
|
162
|
+
// so only it can be shadowed by a shell export.
|
|
163
|
+
if (entry.agent.id === "claude")
|
|
164
|
+
checks.push(checkShadowedExports());
|
|
165
|
+
checks.push(checkRestartNeeded(entry, state.modifiedAt));
|
|
166
|
+
return checks;
|
|
109
167
|
}
|
|
110
168
|
/**
|
|
111
169
|
* Runs the installed hook command with empty stdin.
|
|
112
170
|
*
|
|
113
171
|
* This is a genuine no-op — the hook returns immediately when stdin carries
|
|
114
172
|
* no payload — so it sends nothing and records nothing, while still proving
|
|
115
|
-
* the exact command line in
|
|
173
|
+
* the exact command line in the agent's config resolves and executes. Worth
|
|
116
174
|
* checking directly: hooks run under a shell whose PATH may differ from the
|
|
117
175
|
* interactive one, and a command that fails to resolve there fails
|
|
118
176
|
* silently, costing every session its repository context.
|
|
@@ -165,25 +223,31 @@ function checkShadowedExports() {
|
|
|
165
223
|
* The exporter and the hook both read their configuration once, at process
|
|
166
224
|
* start, so a session already open when setup ran is still using the old
|
|
167
225
|
* values. This is the most common reason a correct configuration looks dead.
|
|
226
|
+
*
|
|
227
|
+
* Scoped to the agent whose hook last ran: one receipt file is shared by all
|
|
228
|
+
* of them, and comparing Claude Code's last run against Codex's config
|
|
229
|
+
* mtime would report a restart that has already happened, or miss one that
|
|
230
|
+
* has not.
|
|
168
231
|
*/
|
|
169
|
-
function checkRestartNeeded(
|
|
232
|
+
function checkRestartNeeded(entry, configModifiedAt) {
|
|
170
233
|
const lastRun = readLastRun();
|
|
171
|
-
|
|
234
|
+
const ranThisAgent = lastRun?.provider === entry.agent.provider;
|
|
235
|
+
if (!configModifiedAt || !lastRun?.at || !ranThisAgent) {
|
|
172
236
|
return {
|
|
173
237
|
label: "Restart",
|
|
174
238
|
ok: "warn",
|
|
175
|
-
detail:
|
|
176
|
-
fix:
|
|
239
|
+
detail: `${entry.agent.label} has not run a hook since this configuration was written`,
|
|
240
|
+
fix: `Restart ${entry.agent.label}, then run 'clocktopus agent status'`,
|
|
177
241
|
};
|
|
178
242
|
}
|
|
179
|
-
const ranAfterConfig = isAfter(parseISO(lastRun.at),
|
|
243
|
+
const ranAfterConfig = isAfter(parseISO(lastRun.at), configModifiedAt);
|
|
180
244
|
return {
|
|
181
245
|
label: "Restart",
|
|
182
246
|
ok: ranAfterConfig ? true : "warn",
|
|
183
247
|
detail: ranAfterConfig
|
|
184
248
|
? "a session has run since the last configuration change"
|
|
185
|
-
: "
|
|
186
|
-
fix:
|
|
249
|
+
: "the config changed after the last session started",
|
|
250
|
+
fix: `Restart ${entry.agent.label} so the exporter and hook pick up the new values`,
|
|
187
251
|
};
|
|
188
252
|
}
|
|
189
253
|
function checkLastHookRun() {
|
|
@@ -192,36 +256,43 @@ function checkLastHookRun() {
|
|
|
192
256
|
return {
|
|
193
257
|
label: "Last hook run",
|
|
194
258
|
ok: "warn",
|
|
195
|
-
detail: "no record —
|
|
196
|
-
fix: "Restart
|
|
259
|
+
detail: "no record — no hook has run on this machine",
|
|
260
|
+
fix: "Restart your agent and start a session",
|
|
197
261
|
};
|
|
198
262
|
}
|
|
199
263
|
const failed = lastRun.status === null ||
|
|
200
264
|
lastRun.status === undefined ||
|
|
201
265
|
lastRun.status < 200 ||
|
|
202
266
|
lastRun.status >= 300;
|
|
267
|
+
const who = lastRun.provider ? ` (${lastRun.provider})` : "";
|
|
203
268
|
return {
|
|
204
269
|
label: "Last hook run",
|
|
205
270
|
ok: !failed,
|
|
206
271
|
detail: failed
|
|
207
|
-
? `${formatAgo(lastRun.at)} — ${lastRun.status ? `HTTP ${lastRun.status}` : (lastRun.error ?? "no response")}`
|
|
208
|
-
: `${formatAgo(lastRun.at)} — HTTP ${lastRun.status}`,
|
|
272
|
+
? `${formatAgo(lastRun.at)}${who} — ${lastRun.status ? `HTTP ${lastRun.status}` : (lastRun.error ?? "no response")}`
|
|
273
|
+
: `${formatAgo(lastRun.at)}${who} — HTTP ${lastRun.status}`,
|
|
209
274
|
fix: lastRun.status === 401
|
|
210
275
|
? "clocktopus agent setup --force (the token was rejected)"
|
|
211
276
|
: "Check network access to the receiver",
|
|
212
277
|
};
|
|
213
278
|
}
|
|
214
|
-
function report(
|
|
215
|
-
console.log("\nAgent telemetry diagnosis
|
|
216
|
-
for (const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
279
|
+
function report(sections) {
|
|
280
|
+
console.log("\nAgent telemetry diagnosis");
|
|
281
|
+
for (const section of sections) {
|
|
282
|
+
if (section.checks.length === 0)
|
|
283
|
+
continue;
|
|
284
|
+
console.log(`\n${section.title}\n`);
|
|
285
|
+
for (const check of section.checks) {
|
|
286
|
+
const symbol = check.ok === true ? "✓" : check.ok === "warn" ? "⚠" : "✗";
|
|
287
|
+
console.log(` ${symbol} ${check.label.padEnd(18)}${check.detail}`);
|
|
288
|
+
if (check.ok !== true && check.fix) {
|
|
289
|
+
console.log(` → ${check.fix}`);
|
|
290
|
+
}
|
|
221
291
|
}
|
|
222
292
|
}
|
|
223
|
-
const
|
|
224
|
-
const
|
|
293
|
+
const all = sections.flatMap((section) => section.checks);
|
|
294
|
+
const failures = all.filter((check) => check.ok === false).length;
|
|
295
|
+
const warnings = all.filter((check) => check.ok === "warn").length;
|
|
225
296
|
console.log("");
|
|
226
297
|
if (failures === 0 && warnings === 0) {
|
|
227
298
|
console.log("Everything checks out. 'clocktopus agent status' shows what has arrived.");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hook.d.ts","sourceRoot":"","sources":["../../../../src/commands/agent/hook.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"hook.d.ts","sourceRoot":"","sources":["../../../../src/commands/agent/hook.ts"],"names":[],"mappings":"AAwaA,wBAAsB,WAAW,CAAC,OAAO,EAAE;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,GAAG,OAAO,CAAC,IAAI,CAAC,CAahB"}
|
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { resolve as resolvePath } from "node:path";
|
|
2
3
|
import { maskToken, resolveAgentCredentials } from "../../lib/agent-config.js";
|
|
3
4
|
import { clearStartState, listStartStates, readStartState, recordLastRun, writeStartState, } from "../../lib/agent-hook-state.js";
|
|
5
|
+
import { AGENTS } from "../../lib/agents.js";
|
|
4
6
|
/**
|
|
5
|
-
*
|
|
7
|
+
* SessionStart / SessionEnd hook → Clocktopus. Serves every agent.
|
|
6
8
|
*
|
|
7
|
-
* Claude Code
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Claude Code and Codex both pipe their hook JSON to stdin, and — verified
|
|
10
|
+
* against 2.1.234 and 0.147.0 — they pipe the *same* JSON: `session_id`,
|
|
11
|
+
* `cwd`, `hook_event_name`, `source`, `reason`, same spellings. That is why
|
|
12
|
+
* one subcommand handles both, and also why `--provider` exists: nothing in
|
|
13
|
+
* the payload says which agent sent it, so the answer has to come from how
|
|
14
|
+
* the hook was installed.
|
|
15
|
+
*
|
|
16
|
+
* That JSON is the only place `cwd` appears anywhere in the telemetry
|
|
17
|
+
* pipeline — neither agent's telemetry export carries path, repository or
|
|
18
|
+
* branch data — so without this hook every agent session is unattributable
|
|
19
|
+
* and the ledger can only show totals.
|
|
12
20
|
*
|
|
13
21
|
* Previously a standalone script under `scripts/agent-telemetry/`, which
|
|
14
22
|
* meant `settings.json` had to reference an absolute path inside a checkout
|
|
@@ -18,6 +26,36 @@ import { clearStartState, listStartStates, readStartState, recordLastRun, writeS
|
|
|
18
26
|
* Contract with the host process, all three parts load-bearing: never write
|
|
19
27
|
* to stdout (Claude Code parses it), never throw, always exit 0. Telemetry
|
|
20
28
|
* must not be able to break the session it measures.
|
|
29
|
+
*
|
|
30
|
+
* ## Getting off the session's critical path
|
|
31
|
+
*
|
|
32
|
+
* This hook does network I/O — its own POST, plus a sweep of abandoned
|
|
33
|
+
* sessions at SessionStart — and none of it may be charged to the developer
|
|
34
|
+
* waiting for their session to start.
|
|
35
|
+
*
|
|
36
|
+
* Claude Code solves this for us: `"async": true` in `settings.json` makes
|
|
37
|
+
* it background the hook. Codex does not, or not reliably — behaviour
|
|
38
|
+
* verified by running both binaries:
|
|
39
|
+
*
|
|
40
|
+
* | `async: true` on | Codex 0.147 | Codex 0.148 |
|
|
41
|
+
* | ---------------- | ------------------------------ | -------------------- |
|
|
42
|
+
* | SessionStart | **skips the hook**, with a warning | honoured, no warning |
|
|
43
|
+
* | SessionEnd | runs it synchronously, with a warning | unchanged |
|
|
44
|
+
*
|
|
45
|
+
* The 0.147 row is the dangerous one: a skipped SessionStart costs every
|
|
46
|
+
* Codex session its `cwd`, and with it the repository, the branch and the
|
|
47
|
+
* starting SHA — spend keeps arriving, attributed to nothing, with only a
|
|
48
|
+
* startup warning to say so.
|
|
49
|
+
*
|
|
50
|
+
* Rather than sniff the version, the Codex hooks carry no `async` key at
|
|
51
|
+
* all and this command backgrounds *itself*: it relays the payload to a
|
|
52
|
+
* detached copy and returns in a few milliseconds. One mechanism, correct
|
|
53
|
+
* on every version, and silent on all of them.
|
|
54
|
+
*
|
|
55
|
+
* SessionEnd needs it regardless of version. Codex still hard-clamps that
|
|
56
|
+
* hook to 3s — shorter than this file's own 4s request timeout — so a slow
|
|
57
|
+
* network would see the hook killed mid-POST and the session never closed.
|
|
58
|
+
* The clamp only has to cover a spawn.
|
|
21
59
|
*/
|
|
22
60
|
const TIMEOUT_MS = 4000;
|
|
23
61
|
/**
|
|
@@ -90,7 +128,7 @@ async function post(endpoint, token, body) {
|
|
|
90
128
|
* receiver stamp one: this runs when the *next* session starts, potentially
|
|
91
129
|
* a day later.
|
|
92
130
|
*/
|
|
93
|
-
async function sweepAbandonedSessions(endpoint, token, currentSessionId) {
|
|
131
|
+
async function sweepAbandonedSessions(endpoint, token, currentSessionId, fallbackProvider) {
|
|
94
132
|
let swept = 0;
|
|
95
133
|
for (const { sessionId, ageMs, lastActivityAt } of listStartStates()) {
|
|
96
134
|
if (swept >= MAX_SWEEP_PER_RUN)
|
|
@@ -107,9 +145,20 @@ async function sweepAbandonedSessions(endpoint, token, currentSessionId) {
|
|
|
107
145
|
clearStartState(sessionId);
|
|
108
146
|
continue;
|
|
109
147
|
}
|
|
148
|
+
// Kept alive only so a repeating SessionEnd could still diff against it.
|
|
149
|
+
// The session already ended properly; sweeping it would replace a real
|
|
150
|
+
// end with this moment's HEAD. Just collect the file.
|
|
151
|
+
if (state.closed) {
|
|
152
|
+
clearStartState(sessionId);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
110
155
|
const head = git(state.cwd, ["rev-parse", "HEAD"]);
|
|
111
156
|
if (head) {
|
|
112
157
|
await post(endpoint, token, {
|
|
158
|
+
// The agent that *opened* the session, not the one sweeping it.
|
|
159
|
+
// Closing a Codex session as `claude_code` would leave the real row
|
|
160
|
+
// hanging and create an empty duplicate under the wrong provider.
|
|
161
|
+
provider: readProvider(state.provider) ?? fallbackProvider,
|
|
113
162
|
session_id: sessionId,
|
|
114
163
|
hook_event_name: "SessionEnd",
|
|
115
164
|
cwd: state.cwd,
|
|
@@ -128,14 +177,59 @@ async function sweepAbandonedSessions(endpoint, token, currentSessionId) {
|
|
|
128
177
|
clearStartState(sessionId);
|
|
129
178
|
}
|
|
130
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Reads a provider back off a state file written by an older CLI.
|
|
182
|
+
*
|
|
183
|
+
* Those files have no `provider` at all, and the only agent that could have
|
|
184
|
+
* written one was Claude Code — so `undefined` is not a missing value here,
|
|
185
|
+
* it is `claude_code`. That is the caller's fallback, applied by returning
|
|
186
|
+
* null rather than guessing here.
|
|
187
|
+
*/
|
|
188
|
+
function readProvider(value) {
|
|
189
|
+
const match = AGENTS.find((agent) => agent.provider === value);
|
|
190
|
+
return match?.provider ?? null;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Re-runs this command in a detached copy and hands it the payload.
|
|
194
|
+
*
|
|
195
|
+
* `detached: true` is what makes it survive: it puts the child in its own
|
|
196
|
+
* process group, so the host tearing down the session's group at exit —
|
|
197
|
+
* which is exactly when SessionEnd fires — cannot take the POST with it.
|
|
198
|
+
*
|
|
199
|
+
* Waiting for `stdin.end` to flush is not optional. The parent returns
|
|
200
|
+
* immediately afterwards, and an unflushed write would die with it; a hook
|
|
201
|
+
* payload is well under the pipe buffer, so this resolves without waiting
|
|
202
|
+
* for the child to read anything.
|
|
203
|
+
*
|
|
204
|
+
* Returns false if the spawn could not be arranged at all, in which case
|
|
205
|
+
* the caller does the work inline — a slow session start is worth more than
|
|
206
|
+
* a lost one.
|
|
207
|
+
*/
|
|
208
|
+
function detachSelf(provider, payload) {
|
|
209
|
+
const script = process.argv[1] ? resolvePath(process.argv[1]) : null;
|
|
210
|
+
if (!script)
|
|
211
|
+
return Promise.resolve(false);
|
|
212
|
+
try {
|
|
213
|
+
const child = spawn(process.execPath, [script, "agent", "hook", "--provider", provider, "--detached"], { detached: true, stdio: ["pipe", "ignore", "ignore"] });
|
|
214
|
+
child.unref();
|
|
215
|
+
return new Promise((settle) => {
|
|
216
|
+
child.once("error", () => settle(false));
|
|
217
|
+
child.stdin.once("error", () => settle(false));
|
|
218
|
+
child.stdin.end(payload, () => settle(true));
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return Promise.resolve(false);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
131
225
|
async function readStdin() {
|
|
132
226
|
const chunks = [];
|
|
133
227
|
for await (const chunk of process.stdin)
|
|
134
228
|
chunks.push(chunk);
|
|
135
229
|
return Buffer.concat(chunks).toString("utf8");
|
|
136
230
|
}
|
|
137
|
-
async function run() {
|
|
138
|
-
const { token, endpoint } = resolveAgentCredentials();
|
|
231
|
+
async function run(agent, detached) {
|
|
232
|
+
const { token, endpoint } = resolveAgentCredentials(agent.id);
|
|
139
233
|
if (!token || !endpoint) {
|
|
140
234
|
// Not configured. Silence is correct: agent telemetry is opt-in, and a
|
|
141
235
|
// machine that never ran `clocktopus agent setup` should notice nothing.
|
|
@@ -144,6 +238,15 @@ async function run() {
|
|
|
144
238
|
const raw = await readStdin();
|
|
145
239
|
if (!raw)
|
|
146
240
|
return;
|
|
241
|
+
// After the empty-stdin check on purpose: `agent doctor` proves the hook
|
|
242
|
+
// command resolves by running it with no payload, and that probe must not
|
|
243
|
+
// leave a stray process behind.
|
|
244
|
+
if (!agent.hostRunsHooksAsync && !detached) {
|
|
245
|
+
if (await detachSelf(agent.provider, raw))
|
|
246
|
+
return;
|
|
247
|
+
// Could not spawn — fall through and do it inline. A slow session start
|
|
248
|
+
// is worth more than a lost session.
|
|
249
|
+
}
|
|
147
250
|
let payload;
|
|
148
251
|
try {
|
|
149
252
|
payload = JSON.parse(raw);
|
|
@@ -165,6 +268,7 @@ async function run() {
|
|
|
165
268
|
const head = git(cwd, ["rev-parse", "HEAD"]);
|
|
166
269
|
const repositoryUrl = git(cwd, ["remote", "get-url", "origin"]);
|
|
167
270
|
const body = {
|
|
271
|
+
provider: agent.provider,
|
|
168
272
|
session_id: sessionId,
|
|
169
273
|
hook_event_name: eventName,
|
|
170
274
|
cwd,
|
|
@@ -183,7 +287,7 @@ async function run() {
|
|
|
183
287
|
// environment.
|
|
184
288
|
if (isSessionStart) {
|
|
185
289
|
if (head)
|
|
186
|
-
writeStartState(sessionId, head, cwd);
|
|
290
|
+
writeStartState(sessionId, { sha: head, cwd, provider: agent.provider });
|
|
187
291
|
}
|
|
188
292
|
else {
|
|
189
293
|
const before = readStartState(sessionId)?.sha;
|
|
@@ -198,7 +302,33 @@ async function run() {
|
|
|
198
302
|
}
|
|
199
303
|
if (before)
|
|
200
304
|
body.git_head_before = before;
|
|
201
|
-
|
|
305
|
+
// A host that ends a session once is done with this file. One that can
|
|
306
|
+
// end it repeatedly — OpenCode, whose SessionEnd is `session.idle` after
|
|
307
|
+
// every turn — is not: deleting it here would cost the session every
|
|
308
|
+
// commit made after its first pause, because the next idle would have no
|
|
309
|
+
// starting SHA to diff against. Rewriting the *original* SHA keeps the
|
|
310
|
+
// range anchored at the session's start, so each idle re-declares the
|
|
311
|
+
// whole range; re-declaring is free, since a link is unique per commit
|
|
312
|
+
// and `declared` already outranks everything else.
|
|
313
|
+
//
|
|
314
|
+
// Rewriting rather than leaving it also refreshes the file's mtime, which
|
|
315
|
+
// is what the sweep ages sessions by — so the 12h abandonment clock runs
|
|
316
|
+
// from the last turn rather than from session start, and a long
|
|
317
|
+
// conversation is not swept out from under itself.
|
|
318
|
+
if (agent.hostRepeatsSessionEnd && before) {
|
|
319
|
+
writeStartState(sessionId, {
|
|
320
|
+
sha: before,
|
|
321
|
+
cwd,
|
|
322
|
+
provider: agent.provider,
|
|
323
|
+
// Already closed in the database. The sweep needs to know that, or
|
|
324
|
+
// it would "close" the session a second time 12h later and stamp it
|
|
325
|
+
// with whatever HEAD had become by then.
|
|
326
|
+
closed: true,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
clearStartState(sessionId);
|
|
331
|
+
}
|
|
202
332
|
}
|
|
203
333
|
// transcript_path is intentionally NOT forwarded. Claude Code provides it,
|
|
204
334
|
// and it points at the full conversation on disk — exactly the content
|
|
@@ -207,6 +337,7 @@ async function run() {
|
|
|
207
337
|
recordLastRun({
|
|
208
338
|
at: new Date().toISOString(),
|
|
209
339
|
event: eventName,
|
|
340
|
+
provider: agent.provider,
|
|
210
341
|
sessionId,
|
|
211
342
|
endpoint,
|
|
212
343
|
tokenPrefix: maskToken(token),
|
|
@@ -218,12 +349,18 @@ async function run() {
|
|
|
218
349
|
// network once per abandoned session and must not delay the event it is
|
|
219
350
|
// piggybacking on.
|
|
220
351
|
if (isSessionStart) {
|
|
221
|
-
await sweepAbandonedSessions(endpoint, token, sessionId);
|
|
352
|
+
await sweepAbandonedSessions(endpoint, token, sessionId, agent.provider);
|
|
222
353
|
}
|
|
223
354
|
}
|
|
224
|
-
export async function hookCommand() {
|
|
355
|
+
export async function hookCommand(options) {
|
|
225
356
|
try {
|
|
226
|
-
|
|
357
|
+
// An unrecognised provider falls back to Claude Code rather than
|
|
358
|
+
// aborting: a hook installed by an older CLI has no flag at all, and
|
|
359
|
+
// dropping its sessions would be a worse failure than mislabelling a
|
|
360
|
+
// provider we do not know.
|
|
361
|
+
const agent = AGENTS.find((candidate) => candidate.provider === options.provider) ??
|
|
362
|
+
AGENTS[0];
|
|
363
|
+
await run(agent, options.detached === true);
|
|
227
364
|
}
|
|
228
365
|
catch {
|
|
229
366
|
// Every failure path is silent by design.
|
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Configures this machine to report
|
|
2
|
+
* Configures this machine's coding agents to report spend to Clocktopus.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
4
|
+
* One command, however many agents are installed. It looks for each
|
|
5
|
+
* supported CLI on PATH and configures what it finds — silently when
|
|
6
|
+
* there is only one, after a prompt when there are several, and from
|
|
7
|
+
* `--agent` when there is neither a person nor a terminal to ask.
|
|
8
|
+
*
|
|
9
|
+
* Everything for a given agent lands in that agent's own config, and
|
|
10
|
+
* nowhere else. A single source of truth per agent is not a tidiness
|
|
11
|
+
* preference: it is what makes `agent doctor` able to say which value is in
|
|
12
|
+
* force. Configuration spread over `.envrc`, a shell profile and a CLI
|
|
13
|
+
* config file can be reported on but never resolved, and this project
|
|
14
|
+
* already lost real spend to exactly that (one session split across two
|
|
15
|
+
* accounts, silently, because two files disagreed about the token).
|
|
12
16
|
*
|
|
13
17
|
* Idempotent by default. Re-running repairs the hooks and refreshes the
|
|
14
18
|
* endpoint while keeping the existing token, so the common case — "did my
|
|
15
|
-
* setup drift?" — costs nothing
|
|
19
|
+
* setup drift?" — costs nothing, and adding a newly-installed agent later
|
|
20
|
+
* reuses the token the first one already has. `--force` mints a
|
|
21
|
+
* replacement instead.
|
|
16
22
|
*/
|
|
17
23
|
export declare function setupCommand(options: {
|
|
18
24
|
name?: string;
|
|
19
25
|
force?: boolean;
|
|
26
|
+
agent?: string[];
|
|
20
27
|
}): Promise<void>;
|
|
21
28
|
//# sourceMappingURL=setup.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"setup.d.ts","sourceRoot":"","sources":["../../../../src/commands/agent/setup.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"setup.d.ts","sourceRoot":"","sources":["../../../../src/commands/agent/setup.ts"],"names":[],"mappings":"AAuBA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE;IAC1C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;CAClB,GAAG,OAAO,CAAC,IAAI,CAAC,CA8LhB"}
|