@blastin-dev/clocktopus-cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +91 -36
  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 +157 -95
  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 +239 -98
  10. package/dist/src/commands/agent/setup.d.ts +1 -16
  11. package/dist/src/commands/agent/setup.d.ts.map +1 -1
  12. package/dist/src/commands/agent/setup.js +198 -81
  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 +5 -23
  18. package/dist/src/lib/agent-config.d.ts.map +1 -1
  19. package/dist/src/lib/agent-config.js +50 -36
  20. package/dist/src/lib/agent-hook-state.d.ts +11 -7
  21. package/dist/src/lib/agent-hook-state.d.ts.map +1 -1
  22. package/dist/src/lib/agent-hook-state.js +34 -29
  23. package/dist/src/lib/agents.d.ts +52 -0
  24. package/dist/src/lib/agents.d.ts.map +1 -0
  25. package/dist/src/lib/agents.js +238 -0
  26. package/dist/src/lib/claude-settings.d.ts +0 -36
  27. package/dist/src/lib/claude-settings.d.ts.map +1 -1
  28. package/dist/src/lib/claude-settings.js +37 -62
  29. package/dist/src/lib/codex-config.d.ts +87 -0
  30. package/dist/src/lib/codex-config.d.ts.map +1 -0
  31. package/dist/src/lib/codex-config.js +399 -0
  32. package/dist/src/lib/codex-config.test.d.ts +2 -0
  33. package/dist/src/lib/codex-config.test.d.ts.map +1 -0
  34. package/dist/src/lib/codex-config.test.js +359 -0
  35. package/dist/src/lib/declared-commits.d.ts +43 -0
  36. package/dist/src/lib/declared-commits.d.ts.map +1 -0
  37. package/dist/src/lib/declared-commits.js +114 -0
  38. package/dist/src/lib/declared-commits.test.d.ts +2 -0
  39. package/dist/src/lib/declared-commits.test.d.ts.map +1 -0
  40. package/dist/src/lib/declared-commits.test.js +129 -0
  41. package/dist/src/lib/git-remotes.d.ts +9 -0
  42. package/dist/src/lib/git-remotes.d.ts.map +1 -0
  43. package/dist/src/lib/git-remotes.js +50 -0
  44. package/dist/src/lib/git-remotes.test.d.ts +2 -0
  45. package/dist/src/lib/git-remotes.test.d.ts.map +1 -0
  46. package/dist/src/lib/git-remotes.test.js +52 -0
  47. package/dist/src/lib/opencode-config.d.ts +23 -0
  48. package/dist/src/lib/opencode-config.d.ts.map +1 -0
  49. package/dist/src/lib/opencode-config.js +290 -0
  50. package/dist/src/lib/opencode-config.test.d.ts +2 -0
  51. package/dist/src/lib/opencode-config.test.d.ts.map +1 -0
  52. package/dist/src/lib/opencode-config.test.js +140 -0
  53. package/package.json +2 -1
@@ -1,35 +1,87 @@
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";
4
- /**
5
- * Claude Code SessionStart / SessionEnd hook → Clocktopus.
6
- *
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.
12
- *
13
- * Previously a standalone script under `scripts/agent-telemetry/`, which
14
- * meant `settings.json` had to reference an absolute path inside a checkout
15
- * of this repository. Shipping it as a subcommand is what makes the feature
16
- * installable by anyone: the hook is wherever the CLI is.
17
- *
18
- * Contract with the host process, all three parts load-bearing: never write
19
- * to stdout (Claude Code parses it), never throw, always exit 0. Telemetry
20
- * must not be able to break the session it measures.
21
- */
5
+ import { AGENTS } from "../../lib/agents.js";
6
+ import { buildReflogArgs, parseReflogCommits, } from "../../lib/declared-commits.js";
7
+ import { parseGitRemotes } from "../../lib/git-remotes.js";
8
+ // SessionStart / SessionEnd hook Clocktopus. Serves every agent.
9
+ //
10
+ // Claude Code and Codex both pipe the *same* hook JSON to stdin `session_id`,
11
+ // `cwd`, `hook_event_name`, `source`, `reason`, same spellings (verified against
12
+ // 2.1.234 and 0.147.0). That is why one subcommand handles both, and why
13
+ // `--provider` exists: nothing in the payload says which agent sent it.
14
+ //
15
+ // That JSON is the only place `cwd` appears anywhere in the pipeline, so without
16
+ // this hook every agent session is unattributable and the ledger can only show
17
+ // totals. Shipping it as a subcommand rather than a script under `scripts/` is what
18
+ // makes the feature installable by anyone: the hook is wherever the CLI is.
19
+ //
20
+ // Contract with the host process, all three parts load-bearing: never write to
21
+ // stdout (Claude Code parses it), never throw, always exit 0.
22
+ //
23
+ // Getting off the session's critical path: this hook does network I/O, and none of
24
+ // it may be charged to the developer waiting for their session. Claude Code's
25
+ // `"async": true` backgrounds it; Codex does not, or not reliably —
26
+ //
27
+ // `async: true` on | Codex 0.147 | Codex 0.148
28
+ // SessionStart | SKIPS the hook, with warning | honoured, no warning
29
+ // SessionEnd | runs it synchronously | unchanged
30
+ //
31
+ // The 0.147 row is the dangerous one: a skipped SessionStart costs every Codex
32
+ // session its `cwd`, and with it the repository, branch and starting SHA. So rather
33
+ // than sniff the version, the Codex hooks carry no `async` key and this command
34
+ // backgrounds *itself*, relaying the payload to a detached copy. SessionEnd needs
35
+ // that regardless: Codex hard-clamps it to 3s, shorter than this file's own 4s
36
+ // request timeout, and the clamp only has to cover a spawn.
22
37
  const TIMEOUT_MS = 4000;
23
- /**
24
- * How long a state file must sit untouched before its session is presumed
25
- * dead. A file only survives SessionEnd if the session never got one
26
- * SIGKILL, a crash, or the machine going down but a long-running session
27
- * is perfectly normal, so this sits well past a working day rather than at
28
- * the point a session merely looks idle.
29
- */
38
+ // How long a state file must sit untouched before its session is presumed dead. A
39
+ // file survives SessionEnd only if the session never got one SIGKILL, a crash, the
40
+ // machine going down but a long-running session is normal, so this sits well past
41
+ // a working day rather than at the point one merely looks idle.
30
42
  const ABANDONED_AFTER_MS = 12 * 60 * 60 * 1000;
31
43
  /** Bound on one sweep, so a directory left full of state cannot stall a start. */
32
44
  const MAX_SWEEP_PER_RUN = 10;
45
+ // The commits this checkout authored inside the session's window. Empty when the
46
+ // session's start was never recorded, which is the deliberate answer — see
47
+ // `buildReflogArgs`.
48
+ function declaredCommits(cwd, startedAt, until) {
49
+ const args = buildReflogArgs({ startedAt, until });
50
+ if (!args)
51
+ return [];
52
+ return parseReflogCommits(git(cwd, args));
53
+ }
54
+ // What the *sweep* may declare, which is nothing at all for most hosts.
55
+ //
56
+ // The upper bound has to be the last moment this machine has evidence the session was
57
+ // alive, and the only such record is the state file's mtime. That is real evidence
58
+ // only where the host re-writes the file as the session goes — OpenCode, whose
59
+ // SessionEnd repeats. Everywhere else the file is written once at SessionStart, so the
60
+ // mtime *is* the start: the window collapses to `[startedAt, startedAt]` and can match
61
+ // nothing the session wrote, only a commit made just before it began. Those would be
62
+ // recorded as `declared`, which outranks the time window and is permanent.
63
+ //
64
+ // Dropping the bound instead is worse. The sweep runs up to twelve hours late, so an
65
+ // unbounded read declares everything the developer committed in between.
66
+ //
67
+ // So: declare nothing, and leave these sessions to `linkCommitsByTimeWindow`, which
68
+ // bounds itself with the server's telemetry-stamped end rather than a file's mtime.
69
+ function sweptCommits(state, lastActivityAt) {
70
+ const host = AGENTS.find((agent) => agent.provider === state.provider);
71
+ if (!host?.hostRepeatsSessionEnd)
72
+ return [];
73
+ return declaredCommits(state.cwd, state.startedAt, lastActivityAt.toISOString());
74
+ }
75
+ /** Omits the field entirely when there is nothing to declare, rather than sending `[]`. */
76
+ function declaredShasFor(shas) {
77
+ return shas.length > 0 ? { commit_shas: shas } : {};
78
+ }
79
+ // Shared by the live hook and the sweep so an abandoned session resolves its
80
+ // repository the same way a live one does.
81
+ function remotesFor(cwd) {
82
+ const remotes = parseGitRemotes(git(cwd, ["remote", "-v"]));
83
+ return { repository_url: remotes[0], repository_urls: remotes };
84
+ }
33
85
  function git(cwd, args) {
34
86
  try {
35
87
  return execFileSync("git", args, {
@@ -59,8 +111,8 @@ async function post(endpoint, token, body) {
59
111
  return { status: response.status, error: null };
60
112
  }
61
113
  catch (error) {
62
- // Telemetry must never break the session it measures. The failure is
63
- // recorded for `agent doctor` instead of surfaced here.
114
+ // Telemetry must never break the session it measures. The failure is recorded for
115
+ // `agent doctor` instead of surfaced here.
64
116
  return {
65
117
  status: null,
66
118
  error: error instanceof Error ? error.message : "request failed",
@@ -70,27 +122,20 @@ async function post(endpoint, token, body) {
70
122
  clearTimeout(timer);
71
123
  }
72
124
  }
73
- /**
74
- * Closes sessions that were never given a SessionEnd.
75
- *
76
- * A state file outlives its session only when the process died without
77
- * running the hook, and until something closes it the session keeps a null
78
- * `ended_at` and a null `git_head_after` forever.
79
- *
80
- * It deliberately does **not** send `commit_shas`. The honest range for a
81
- * dead session is unknowable after the fact: `before..HEAD` also contains
82
- * everything committed in the hours since it died, and the receiver records
83
- * hook-supplied commits as `declared`, which outranks a `time_window` link
84
- * and would make a bad guess permanent. Closing the session is enough —
85
- * with a real `ended_at` the webhook's time-window pass can then link its
86
- * commits from the commit timestamps, which are bounded by data rather than
87
- * by assumption.
88
- *
89
- * For the same reason it sends its own `ended_at` rather than letting the
90
- * receiver stamp one: this runs when the *next* session starts, potentially
91
- * a day later.
92
- */
93
- async function sweepAbandonedSessions(endpoint, token, currentSessionId) {
125
+ // Closes sessions that were never given a SessionEnd, which otherwise keep a null
126
+ // `ended_at` and `git_head_after` forever.
127
+ //
128
+ // It now declares commits too, which a range could never let it do. `before..HEAD`
129
+ // resolved after the fact also contains everything committed in the hours since, and
130
+ // a hook-supplied commit is recorded as `declared`, which outranks a `time_window`
131
+ // link — a bad guess would be permanent. The reflog is bounded by data instead: it is
132
+ // on disk, it is stamped, and the session's own window brackets it. That closes the
133
+ // case this whole area exists for, an agent left running unattended and killed before
134
+ // it could report anything.
135
+ //
136
+ // It sends its own `ended_at` for a related reason: this runs when the *next* session
137
+ // starts, potentially a day later.
138
+ async function sweepAbandonedSessions(endpoint, token, currentSessionId, fallbackProvider) {
94
139
  let swept = 0;
95
140
  for (const { sessionId, ageMs, lastActivityAt } of listStartStates()) {
96
141
  if (swept >= MAX_SWEEP_PER_RUN)
@@ -101,49 +146,116 @@ async function sweepAbandonedSessions(endpoint, token, currentSessionId) {
101
146
  continue;
102
147
  swept++;
103
148
  const state = readStartState(sessionId);
104
- // Nothing recoverable without the originating checkout — drop the file
105
- // rather than resolve its SHA against an unrelated repository.
149
+ // Nothing recoverable without the originating checkout — drop the file rather than
150
+ // resolve its SHA against an unrelated repository.
106
151
  if (!state?.cwd) {
107
152
  clearStartState(sessionId);
108
153
  continue;
109
154
  }
155
+ // Kept alive only so a repeating SessionEnd could still diff against it. The session
156
+ // already ended properly; sweeping it would replace a real end with this moment's
157
+ // HEAD.
158
+ if (state.closed) {
159
+ clearStartState(sessionId);
160
+ continue;
161
+ }
110
162
  const head = git(state.cwd, ["rev-parse", "HEAD"]);
111
163
  if (head) {
112
164
  await post(endpoint, token, {
165
+ // The agent that *opened* the session, not the one sweeping it. Closing a Codex
166
+ // session as `claude_code` would leave the real row hanging and create an empty
167
+ // duplicate under the wrong provider.
168
+ provider: readProvider(state.provider) ?? fallbackProvider,
113
169
  session_id: sessionId,
114
170
  hook_event_name: "SessionEnd",
115
171
  cwd: state.cwd,
116
172
  reason: "abandoned",
117
- // Never "now": this session died hours ago, and the receiver would
118
- // otherwise record a window spanning everything since. The receiver
119
- // treats this as a fallback and keeps a telemetry-stamped end if it
120
- // has one; this value only fills the gap when it has none.
173
+ // Never "now": this session died hours ago. The receiver treats this as a fallback
174
+ // and keeps a telemetry-stamped end if it has one.
121
175
  ended_at: lastActivityAt.toISOString(),
122
- repository_url: git(state.cwd, ["remote", "get-url", "origin"]),
123
- git_branch: git(state.cwd, ["rev-parse", "--abbrev-ref", "HEAD"]),
176
+ // A remote is a property of the checkout and does not drift; the branch is a
177
+ // property of the moment, and this moment is hours after the session died.
178
+ // Sending one would overwrite the branch SessionStart recorded — `COALESCE` lets
179
+ // any non-null value win — replacing real evidence with wherever the developer
180
+ // happens to be now. Omitted so the recorded branch survives.
181
+ ...remotesFor(state.cwd),
182
+ ...declaredShasFor(
183
+ // Spread rather than passed whole so the `state.cwd` guard above narrows.
184
+ sweptCommits({
185
+ cwd: state.cwd,
186
+ provider: state.provider,
187
+ startedAt: state.startedAt,
188
+ }, lastActivityAt)),
124
189
  git_head_before: state.sha,
190
+ // Same objection as the branch, kept only because nothing reads it: it is
191
+ // diagnostic, and removing it would change what `agent doctor` shows for no gain.
125
192
  git_head_after: head,
126
193
  });
127
194
  }
128
195
  clearStartState(sessionId);
129
196
  }
130
197
  }
198
+ // Reads a provider back off a state file written by an older CLI. Those files have
199
+ // no `provider`, and the only agent that could have written one was Claude Code — so
200
+ // `undefined` is not a missing value, it is `claude_code`. Returning null lets the
201
+ // caller apply that fallback rather than guessing here.
202
+ function readProvider(value) {
203
+ const match = AGENTS.find((agent) => agent.provider === value);
204
+ return match?.provider ?? null;
205
+ }
206
+ // Re-runs this command in a detached copy and hands it the payload.
207
+ //
208
+ // `detached: true` puts the child in its own process group, so the host tearing down
209
+ // the session's group at exit — exactly when SessionEnd fires — cannot take the POST
210
+ // with it.
211
+ //
212
+ // Waiting for `stdin.end` to flush is not optional: the parent returns immediately
213
+ // afterwards and an unflushed write would die with it. A hook payload is well under
214
+ // the pipe buffer, so this resolves without waiting for the child to read.
215
+ //
216
+ // False if the spawn could not be arranged, in which case the caller works inline.
217
+ function detachSelf(provider, payload) {
218
+ const script = process.argv[1] ? resolvePath(process.argv[1]) : null;
219
+ if (!script)
220
+ return Promise.resolve(false);
221
+ try {
222
+ const child = spawn(process.execPath, [script, "agent", "hook", "--provider", provider, "--detached"], { detached: true, stdio: ["pipe", "ignore", "ignore"] });
223
+ child.unref();
224
+ return new Promise((settle) => {
225
+ child.once("error", () => settle(false));
226
+ child.stdin.once("error", () => settle(false));
227
+ child.stdin.end(payload, () => settle(true));
228
+ });
229
+ }
230
+ catch {
231
+ return Promise.resolve(false);
232
+ }
233
+ }
131
234
  async function readStdin() {
132
235
  const chunks = [];
133
236
  for await (const chunk of process.stdin)
134
237
  chunks.push(chunk);
135
238
  return Buffer.concat(chunks).toString("utf8");
136
239
  }
137
- async function run() {
138
- const { token, endpoint } = resolveAgentCredentials();
240
+ async function run(agent, detached) {
241
+ const { token, endpoint } = resolveAgentCredentials(agent.id);
139
242
  if (!token || !endpoint) {
140
- // Not configured. Silence is correct: agent telemetry is opt-in, and a
141
- // machine that never ran `clocktopus agent setup` should notice nothing.
243
+ // Not configured. Silence is correct: agent telemetry is opt-in, and a machine that
244
+ // never ran `clocktopus agent setup` should notice nothing.
142
245
  return;
143
246
  }
144
247
  const raw = await readStdin();
145
248
  if (!raw)
146
249
  return;
250
+ // After the empty-stdin check on purpose: `agent doctor` proves the hook command
251
+ // resolves by running it with no payload, and that probe must not leave a stray
252
+ // process behind.
253
+ if (!agent.hostRunsHooksAsync && !detached) {
254
+ if (await detachSelf(agent.provider, raw))
255
+ return;
256
+ // Could not spawn — fall through and do it inline. A slow session start is worth
257
+ // more than a lost one.
258
+ }
147
259
  let payload;
148
260
  try {
149
261
  payload = JSON.parse(raw);
@@ -157,73 +269,102 @@ async function run() {
157
269
  return;
158
270
  const eventName = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
159
271
  const isSessionStart = eventName === "SessionStart";
160
- // HEAD at SessionStart is the *before* SHA; HEAD at SessionEnd is the
161
- // *after*. Sending both under one field would let SessionEnd overwrite the
162
- // start SHA, collapsing the range to a single point and making
163
- // `git rev-list before..after` — the authoritative commit↔session link —
164
- // impossible to compute.
272
+ // HEAD at SessionStart is the *before* SHA, at SessionEnd the *after*. Kept as two
273
+ // fields so SessionEnd cannot overwrite the start SHA; the commits themselves now
274
+ // come from the reflog, but the range is still what `repo-readiness` reports on.
165
275
  const head = git(cwd, ["rev-parse", "HEAD"]);
166
- const repositoryUrl = git(cwd, ["remote", "get-url", "origin"]);
167
276
  const body = {
277
+ provider: agent.provider,
168
278
  session_id: sessionId,
169
279
  hook_event_name: eventName,
170
280
  cwd,
171
281
  source: typeof payload.source === "string" ? payload.source : undefined,
172
282
  reason: typeof payload.reason === "string" ? payload.reason : undefined,
173
- repository_url: repositoryUrl,
283
+ ...remotesFor(cwd),
174
284
  git_branch: git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]),
175
285
  ...(isSessionStart ? { git_head_before: head } : { git_head_after: head }),
176
286
  };
177
- // Resolve the SHA range into an actual commit list, here on the machine
178
- // that has the git object graph. The receiver cannot do this it has no
179
- // checkout so a range alone would be unusable.
180
- //
181
- // SessionStart and SessionEnd are separate processes, so the starting SHA
182
- // is stashed on disk under the session id rather than passed in the
183
- // environment.
287
+ // Resolve the session's commits here, on the machine that has the git object graph
288
+ // and the reflog the receiver has neither. SessionStart and SessionEnd are separate
289
+ // processes, so the session's start is stashed on disk under the session id rather
290
+ // than passed in the environment.
184
291
  if (isSessionStart) {
185
- if (head)
186
- writeStartState(sessionId, head, cwd);
292
+ // SessionStart fires again mid-session on `compact` and `resume`, and our hook is
293
+ // installed with no matcher, so it sees those too. Writing again would move
294
+ // `startedAt` forward and drop everything committed before the compaction out of
295
+ // the declared window — and move the server's `started_at` with it. The first
296
+ // stamp is the session's real start; keep it.
297
+ if (head && !readStartState(sessionId))
298
+ writeStartState(sessionId, {
299
+ sha: head,
300
+ cwd,
301
+ provider: agent.provider,
302
+ startedAt: new Date().toISOString(),
303
+ });
187
304
  }
188
305
  else {
189
- const before = readStartState(sessionId)?.sha;
190
- if (before && head && before !== head) {
191
- const shas = git(cwd, [
192
- "rev-list",
193
- "--max-count=200",
194
- `${before}..${head}`,
195
- ]);
196
- if (shas)
197
- body.commit_shas = shas.split("\n").filter(Boolean);
198
- }
306
+ const start = readStartState(sessionId);
307
+ const before = start?.sha;
308
+ const commitShas = declaredCommits(cwd, start?.startedAt);
309
+ if (commitShas.length > 0)
310
+ body.commit_shas = commitShas;
199
311
  if (before)
200
312
  body.git_head_before = before;
201
- clearStartState(sessionId);
313
+ // A host that ends a session once is done with this file. One that can end it
314
+ // repeatedly — OpenCode, whose SessionEnd is `session.idle` after every turn — is
315
+ // not: deleting it here would cost the session every commit made after its first
316
+ // pause. Rewriting the *original* SHA keeps the range anchored at the session's
317
+ // start, and re-declaring is free since a link is unique per commit.
318
+ //
319
+ // Rewriting also refreshes the file's mtime, which the sweep ages sessions by, so
320
+ // the 12h clock runs from the last turn and a long conversation is not swept out
321
+ // from under itself.
322
+ if (agent.hostRepeatsSessionEnd && before) {
323
+ writeStartState(sessionId, {
324
+ sha: before,
325
+ cwd,
326
+ provider: agent.provider,
327
+ // Carried over with the SHA: the range stays anchored at the session's start, so
328
+ // the bound on it has to as well, or every turn after the first would declare
329
+ // against an unbounded range again.
330
+ startedAt: start?.startedAt,
331
+ // Already closed in the database. The sweep needs to know, or it would "close" the
332
+ // session again 12h later and stamp it with whatever HEAD had become by then.
333
+ closed: true,
334
+ });
335
+ }
336
+ else {
337
+ clearStartState(sessionId);
338
+ }
202
339
  }
203
- // transcript_path is intentionally NOT forwarded. Claude Code provides it,
204
- // and it points at the full conversation on disk exactly the content
205
- // BLA-421 says we must never collect.
340
+ // transcript_path is intentionally NOT forwarded: it points at the full conversation
341
+ // on disk, exactly the content BLA-421 says we must never collect.
206
342
  const result = await post(endpoint, token, body);
207
343
  recordLastRun({
208
344
  at: new Date().toISOString(),
209
345
  event: eventName,
346
+ provider: agent.provider,
210
347
  sessionId,
211
348
  endpoint,
212
349
  tokenPrefix: maskToken(token),
213
350
  status: result.status,
214
351
  error: result.error,
215
- repository: repositoryUrl ?? null,
352
+ repository: body.repository_url ?? null,
216
353
  });
217
- // After this session's own event, never before it: the sweep talks to the
218
- // network once per abandoned session and must not delay the event it is
219
- // piggybacking on.
354
+ // After this session's own event, never before it: the sweep talks to the network
355
+ // once per abandoned session and must not delay the event it piggybacks on.
220
356
  if (isSessionStart) {
221
- await sweepAbandonedSessions(endpoint, token, sessionId);
357
+ await sweepAbandonedSessions(endpoint, token, sessionId, agent.provider);
222
358
  }
223
359
  }
224
- export async function hookCommand() {
360
+ export async function hookCommand(options) {
225
361
  try {
226
- await run();
362
+ // An unrecognised provider falls back to Claude Code rather than aborting: a hook
363
+ // installed by an older CLI has no flag at all, and dropping its sessions would be a
364
+ // worse failure than mislabelling a provider we do not know.
365
+ const agent = AGENTS.find((candidate) => candidate.provider === options.provider) ??
366
+ AGENTS[0];
367
+ await run(agent, options.detached === true);
227
368
  }
228
369
  catch {
229
370
  // Every failure path is silent by design.
@@ -1,21 +1,6 @@
1
- /**
2
- * Configures this machine to report agent spend to Clocktopus.
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).
12
- *
13
- * Idempotent by default. Re-running repairs the hooks and refreshes the
14
- * endpoint while keeping the existing token, so the common case — "did my
15
- * setup drift?" — costs nothing. `--force` mints a replacement instead.
16
- */
17
1
  export declare function setupCommand(options: {
18
2
  name?: string;
19
3
  force?: boolean;
4
+ agent?: string[];
20
5
  }): Promise<void>;
21
6
  //# 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":"AAsCA,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,CA0LhB"}