@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.
Files changed (38) hide show
  1. package/README.md +89 -34
  2. package/dist/src/commands/agent/disable.d.ts +8 -1
  3. package/dist/src/commands/agent/disable.d.ts.map +1 -1
  4. package/dist/src/commands/agent/disable.js +79 -45
  5. package/dist/src/commands/agent/doctor.d.ts.map +1 -1
  6. package/dist/src/commands/agent/doctor.js +146 -75
  7. package/dist/src/commands/agent/hook.d.ts +4 -1
  8. package/dist/src/commands/agent/hook.d.ts.map +1 -1
  9. package/dist/src/commands/agent/hook.js +152 -15
  10. package/dist/src/commands/agent/setup.d.ts +17 -10
  11. package/dist/src/commands/agent/setup.d.ts.map +1 -1
  12. package/dist/src/commands/agent/setup.js +208 -69
  13. package/dist/src/commands/agent/status.d.ts.map +1 -1
  14. package/dist/src/commands/agent/status.js +46 -24
  15. package/dist/src/index.d.ts.map +1 -1
  16. package/dist/src/index.js +18 -4
  17. package/dist/src/lib/agent-config.d.ts +18 -3
  18. package/dist/src/lib/agent-config.d.ts.map +1 -1
  19. package/dist/src/lib/agent-config.js +44 -19
  20. package/dist/src/lib/agent-hook-state.d.ts +9 -1
  21. package/dist/src/lib/agent-hook-state.d.ts.map +1 -1
  22. package/dist/src/lib/agent-hook-state.js +21 -2
  23. package/dist/src/lib/agents.d.ts +115 -0
  24. package/dist/src/lib/agents.d.ts.map +1 -0
  25. package/dist/src/lib/agents.js +245 -0
  26. package/dist/src/lib/codex-config.d.ts +166 -0
  27. package/dist/src/lib/codex-config.d.ts.map +1 -0
  28. package/dist/src/lib/codex-config.js +441 -0
  29. package/dist/src/lib/codex-config.test.d.ts +2 -0
  30. package/dist/src/lib/codex-config.test.d.ts.map +1 -0
  31. package/dist/src/lib/codex-config.test.js +359 -0
  32. package/dist/src/lib/opencode-config.d.ts +108 -0
  33. package/dist/src/lib/opencode-config.d.ts.map +1 -0
  34. package/dist/src/lib/opencode-config.js +330 -0
  35. package/dist/src/lib/opencode-config.test.d.ts +2 -0
  36. package/dist/src/lib/opencode-config.test.d.ts.map +1 -0
  37. package/dist/src/lib/opencode-config.test.js +140 -0
  38. 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 { readInstalledTelemetry, SettingsParseError, TELEMETRY_ENV_KEYS, } from "../../lib/claude-settings.js";
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: "Logged in",
14
- ok: isLoggedIn(),
15
- detail: isLoggedIn() ? "yes" : "no",
16
- fix: "clocktopus login",
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 installed = null;
70
+ let state;
19
71
  try {
20
- installed = readInstalledTelemetry();
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 instanceof SettingsParseError) {
32
- checks.push({
33
- label: "settings.json",
75
+ if (!isConfigParseError(error))
76
+ throw error;
77
+ return [
78
+ {
79
+ label: "Config",
34
80
  ok: false,
35
- detail: "is not valid JSON",
36
- fix: "Fix or move the file, then run 'clocktopus agent setup'",
37
- });
38
- report(checks);
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: "Exporter config",
46
- ok: missingEnv.length === 0,
47
- detail: missingEnv.length === 0
48
- ? `all ${TELEMETRY_ENV_KEYS.length} variables set`
49
- : // Listing nine names when none are set is noise; the useful
50
- // signal there is simply "nothing is configured".
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 = installed.hookCommands.SessionStart;
61
- const hookEnd = installed.hookCommands.SessionEnd;
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"].filter(Boolean).join(", ")}`,
68
- fix: "clocktopus agent setup",
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: "clocktopus agent setup",
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
- // Past this point the pipeline works; these two decide whether what it
100
- // carries can be turned into a number. Both are server-side facts about
101
- // the repository, not about this machine.
102
- const repoStatus = await fetchRepoStatus();
103
- if (repoStatus)
104
- checks.push(...repoChecks(repoStatus));
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 settings.json resolves and executes. Worth
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(settingsModifiedAt) {
232
+ function checkRestartNeeded(entry, configModifiedAt) {
170
233
  const lastRun = readLastRun();
171
- if (!settingsModifiedAt || !lastRun?.at) {
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: "the hook has not run since this configuration was written",
176
- fix: "Restart Claude Code, then run 'clocktopus agent status'",
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), settingsModifiedAt);
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
- : "settings.json changed after the last session started",
186
- fix: "Restart Claude Code so the exporter and hook pick up the new values",
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 — the hook has not run on this machine",
196
- fix: "Restart Claude Code and start a session",
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(checks) {
215
- console.log("\nAgent telemetry diagnosis\n");
216
- for (const check of checks) {
217
- const symbol = check.ok === true ? "✓" : check.ok === "warn" ? "⚠" : "✗";
218
- console.log(` ${symbol} ${check.label.padEnd(18)}${check.detail}`);
219
- if (check.ok !== true && check.fix) {
220
- console.log(` → ${check.fix}`);
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 failures = checks.filter((check) => check.ok === false).length;
224
- const warnings = checks.filter((check) => check.ok === "warn").length;
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,2 +1,5 @@
1
- export declare function hookCommand(): Promise<void>;
1
+ export declare function hookCommand(options: {
2
+ provider?: string;
3
+ detached?: boolean;
4
+ }): Promise<void>;
2
5
  //# sourceMappingURL=hook.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"hook.d.ts","sourceRoot":"","sources":["../../../../src/commands/agent/hook.ts"],"names":[],"mappings":"AAuRA,wBAAsB,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC,CAMjD"}
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
- * Claude Code SessionStart / SessionEnd hook → Clocktopus.
7
+ * SessionStart / SessionEnd hook → Clocktopus. Serves every agent.
6
8
  *
7
- * Claude Code pipes its hook JSON to stdin. That JSON is the only place
8
- * `cwd` appears anywhere in the telemetry pipeline the OTLP metric export
9
- * carries no path, repository or branch data at all — so without this hook
10
- * every agent session is unattributable and the ledger can only show
11
- * totals.
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
- clearStartState(sessionId);
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
- await run();
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 agent spend to Clocktopus.
2
+ * Configures this machine's coding agents to report spend to Clocktopus.
3
3
  *
4
- * Everything lands in one file `~/.claude/settings.json` holding both
5
- * the exporter's environment and the session hooks. A single source of
6
- * truth is not a tidiness preference: it is what makes `agent doctor` able
7
- * to say which value is in force. Configuration spread over `.envrc`, a
8
- * shell profile and a CLI config file can be reported on but never
9
- * resolved, and this project already lost real spend to exactly that (one
10
- * session split across two accounts, silently, because two files disagreed
11
- * about the token).
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. `--force` mints a replacement instead.
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":"AA4BA;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE;IAC1C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,GAAG,OAAO,CAAC,IAAI,CAAC,CA+IhB"}
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"}