@blastin-dev/clocktopus-cli 0.2.1 → 0.3.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 (41) hide show
  1. package/README.md +2 -2
  2. package/dist/src/commands/agent/doctor.d.ts.map +1 -1
  3. package/dist/src/commands/agent/doctor.js +26 -35
  4. package/dist/src/commands/agent/hook.d.ts.map +1 -1
  5. package/dist/src/commands/agent/hook.js +184 -180
  6. package/dist/src/commands/agent/setup.d.ts +0 -22
  7. package/dist/src/commands/agent/setup.d.ts.map +1 -1
  8. package/dist/src/commands/agent/setup.js +40 -62
  9. package/dist/src/lib/agent-config.d.ts +0 -33
  10. package/dist/src/lib/agent-config.d.ts.map +1 -1
  11. package/dist/src/lib/agent-config.js +15 -26
  12. package/dist/src/lib/agent-hook-state.d.ts +2 -6
  13. package/dist/src/lib/agent-hook-state.d.ts.map +1 -1
  14. package/dist/src/lib/agent-hook-state.js +29 -43
  15. package/dist/src/lib/agents.d.ts +0 -63
  16. package/dist/src/lib/agents.d.ts.map +1 -1
  17. package/dist/src/lib/agents.js +19 -26
  18. package/dist/src/lib/auth.d.ts.map +1 -1
  19. package/dist/src/lib/auth.js +11 -0
  20. package/dist/src/lib/claude-settings.d.ts +0 -36
  21. package/dist/src/lib/claude-settings.d.ts.map +1 -1
  22. package/dist/src/lib/claude-settings.js +37 -62
  23. package/dist/src/lib/codex-config.d.ts +0 -79
  24. package/dist/src/lib/codex-config.d.ts.map +1 -1
  25. package/dist/src/lib/codex-config.js +74 -116
  26. package/dist/src/lib/declared-commits.d.ts +43 -0
  27. package/dist/src/lib/declared-commits.d.ts.map +1 -0
  28. package/dist/src/lib/declared-commits.js +114 -0
  29. package/dist/src/lib/declared-commits.test.d.ts +2 -0
  30. package/dist/src/lib/declared-commits.test.d.ts.map +1 -0
  31. package/dist/src/lib/declared-commits.test.js +129 -0
  32. package/dist/src/lib/git-remotes.d.ts +9 -0
  33. package/dist/src/lib/git-remotes.d.ts.map +1 -0
  34. package/dist/src/lib/git-remotes.js +50 -0
  35. package/dist/src/lib/git-remotes.test.d.ts +2 -0
  36. package/dist/src/lib/git-remotes.test.d.ts.map +1 -0
  37. package/dist/src/lib/git-remotes.test.js +52 -0
  38. package/dist/src/lib/opencode-config.d.ts +0 -85
  39. package/dist/src/lib/opencode-config.d.ts.map +1 -1
  40. package/dist/src/lib/opencode-config.js +72 -112
  41. package/package.json +5 -5
package/README.md CHANGED
@@ -149,8 +149,8 @@ things decide whether they mean anything, and neither is visible locally:
149
149
  | Repository attached to a project | Spend is recorded, but lands in the unattributed row instead of against a client |
150
150
  | Push webhook delivering | No human time entries, so there is nothing to compare the spend against |
151
151
 
152
- The second does **not** stop agent→commit links — the SessionEnd hook
153
- resolves `git rev-list before..after` locally and posts the list, so those
152
+ The second does **not** stop agent→commit links — the SessionEnd hook reads
153
+ your checkout's own reflog locally and posts the commits it authored, so those
154
154
  are created with no webhook involved. What is missing is the other side of
155
155
  the comparison: true cost is human time × rate _plus_ agent spend, and one
156
156
  term alone is not a smaller answer.
@@ -1 +1 @@
1
- {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../../../src/commands/agent/doctor.ts"],"names":[],"mappings":"AAuCA,wBAAsB,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CA8CnD"}
1
+ {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../../../src/commands/agent/doctor.ts"],"names":[],"mappings":"AAmCA,wBAAsB,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CA6CnD"}
@@ -36,9 +36,8 @@ export async function doctorCommand() {
36
36
  checks: await agentChecks(entry),
37
37
  });
38
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.
39
+ // Past this point the pipeline works; these decide whether what it carries can be turned
40
+ // into a number. Both are server-side facts about the repository, not about any one agent.
42
41
  const tail = [];
43
42
  const repoStatus = await fetchRepoStatus();
44
43
  if (repoStatus)
@@ -91,10 +90,9 @@ async function agentChecks(entry) {
91
90
  : `not configured in ${state.paths.join(", ")}`,
92
91
  fix: `clocktopus agent setup --agent ${entry.agent.id}`,
93
92
  });
94
- // Both events are required, and for different reasons: SessionStart is the
95
- // only source of the *before* SHA and of `cwd`, SessionEnd is the only
96
- // source of the exact commit list. One without the other silently
97
- // degrades what can be attributed.
93
+ // Both events are required, for different reasons: SessionStart is the only source of the
94
+ // *before* SHA and of `cwd`, SessionEnd the only source of the exact commit list. One
95
+ // without the other silently degrades what can be attributed.
98
96
  const hookStart = state.hookCommands.SessionStart;
99
97
  const hookEnd = state.hookCommands.SessionEnd;
100
98
  checks.push({
@@ -109,10 +107,9 @@ async function agentChecks(entry) {
109
107
  });
110
108
  if (hookStart)
111
109
  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.
110
+ // A warning, not a failure: a plugin from an older generation still reports. What it does
111
+ // not have is whatever the newer one fixed, and nothing else in this report would say so
112
+ // — the token is valid, the hook resolves, the receiver answers.
116
113
  const stale = entry.agent.staleReason?.() ?? null;
117
114
  if (stale) {
118
115
  checks.push({
@@ -158,23 +155,20 @@ async function agentChecks(entry) {
158
155
  : "clocktopus agent setup --force (mints a replacement token)",
159
156
  });
160
157
  }
161
- // Only Claude Code applies config `env` over the inherited environment,
162
- // so only it can be shadowed by a shell export.
158
+ // Only Claude Code applies config `env` over the inherited environment, so only it can be
159
+ // shadowed by a shell export.
163
160
  if (entry.agent.id === "claude")
164
161
  checks.push(checkShadowedExports());
165
162
  checks.push(checkRestartNeeded(entry, state.modifiedAt));
166
163
  return checks;
167
164
  }
168
- /**
169
- * Runs the installed hook command with empty stdin.
170
- *
171
- * This is a genuine no-op the hook returns immediately when stdin carries
172
- * no payload so it sends nothing and records nothing, while still proving
173
- * the exact command line in the agent's config resolves and executes. Worth
174
- * checking directly: hooks run under a shell whose PATH may differ from the
175
- * interactive one, and a command that fails to resolve there fails
176
- * silently, costing every session its repository context.
177
- */
165
+ // Runs the installed hook command with empty stdin.
166
+ //
167
+ // A genuine no-op — the hook returns immediately when stdin carries no payload — so it
168
+ // sends and records nothing while still proving the exact command line in the agent's
169
+ // config resolves and executes. Worth checking directly: hooks run under a shell whose
170
+ // PATH may differ from the interactive one, and a command that fails to resolve there
171
+ // fails silently, costing every session its repository context.
178
172
  function checkHookRuns(command) {
179
173
  try {
180
174
  execSync(command, {
@@ -217,18 +211,15 @@ function checkShadowedExports() {
217
211
  " disagree is how spend lands on the wrong account.",
218
212
  };
219
213
  }
220
- /**
221
- * Did the configuration change after the last session used it?
222
- *
223
- * The exporter and the hook both read their configuration once, at process
224
- * start, so a session already open when setup ran is still using the old
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.
231
- */
214
+ // Did the configuration change after the last session used it?
215
+ //
216
+ // The exporter and the hook both read their configuration once, at process start, so a
217
+ // session already open when setup ran is still using the old values. This is the most
218
+ // common reason a correct configuration looks dead.
219
+ //
220
+ // Scoped to the agent whose hook last ran: one receipt file is shared by all of them, and
221
+ // comparing Claude Code's last run against Codex's config mtime would report a restart
222
+ // that has already happened, or miss one that has not.
232
223
  function checkRestartNeeded(entry, configModifiedAt) {
233
224
  const lastRun = readLastRun();
234
225
  const ranThisAgent = lastRun?.provider === entry.agent.provider;
@@ -1 +1 @@
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
+ {"version":3,"file":"hook.d.ts","sourceRoot":"","sources":["../../../../src/commands/agent/hook.ts"],"names":[],"mappings":"AAmcA,wBAAsB,WAAW,CAAC,OAAO,EAAE;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,GAAG,OAAO,CAAC,IAAI,CAAC,CAYhB"}
@@ -3,71 +3,85 @@ import { resolve as resolvePath } from "node:path";
3
3
  import { maskToken, resolveAgentCredentials } from "../../lib/agent-config.js";
4
4
  import { clearStartState, listStartStates, readStartState, recordLastRun, writeStartState, } from "../../lib/agent-hook-state.js";
5
5
  import { AGENTS } from "../../lib/agents.js";
6
- /**
7
- * SessionStart / SessionEnd hook → Clocktopus. Serves every agent.
8
- *
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.
20
- *
21
- * Previously a standalone script under `scripts/agent-telemetry/`, which
22
- * meant `settings.json` had to reference an absolute path inside a checkout
23
- * of this repository. Shipping it as a subcommand is what makes the feature
24
- * installable by anyone: the hook is wherever the CLI is.
25
- *
26
- * Contract with the host process, all three parts load-bearing: never write
27
- * to stdout (Claude Code parses it), never throw, always exit 0. Telemetry
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.
59
- */
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.
60
37
  const TIMEOUT_MS = 4000;
61
- /**
62
- * How long a state file must sit untouched before its session is presumed
63
- * dead. A file only survives SessionEnd if the session never got one
64
- * SIGKILL, a crash, or the machine going down but a long-running session
65
- * is perfectly normal, so this sits well past a working day rather than at
66
- * the point a session merely looks idle.
67
- */
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.
68
42
  const ABANDONED_AFTER_MS = 12 * 60 * 60 * 1000;
69
43
  /** Bound on one sweep, so a directory left full of state cannot stall a start. */
70
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
+ }
71
85
  function git(cwd, args) {
72
86
  try {
73
87
  return execFileSync("git", args, {
@@ -97,8 +111,8 @@ async function post(endpoint, token, body) {
97
111
  return { status: response.status, error: null };
98
112
  }
99
113
  catch (error) {
100
- // Telemetry must never break the session it measures. The failure is
101
- // 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.
102
116
  return {
103
117
  status: null,
104
118
  error: error instanceof Error ? error.message : "request failed",
@@ -108,26 +122,19 @@ async function post(endpoint, token, body) {
108
122
  clearTimeout(timer);
109
123
  }
110
124
  }
111
- /**
112
- * Closes sessions that were never given a SessionEnd.
113
- *
114
- * A state file outlives its session only when the process died without
115
- * running the hook, and until something closes it the session keeps a null
116
- * `ended_at` and a null `git_head_after` forever.
117
- *
118
- * It deliberately does **not** send `commit_shas`. The honest range for a
119
- * dead session is unknowable after the fact: `before..HEAD` also contains
120
- * everything committed in the hours since it died, and the receiver records
121
- * hook-supplied commits as `declared`, which outranks a `time_window` link
122
- * and would make a bad guess permanent. Closing the session is enough —
123
- * with a real `ended_at` the webhook's time-window pass can then link its
124
- * commits from the commit timestamps, which are bounded by data rather than
125
- * by assumption.
126
- *
127
- * For the same reason it sends its own `ended_at` rather than letting the
128
- * receiver stamp one: this runs when the *next* session starts, potentially
129
- * a day later.
130
- */
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.
131
138
  async function sweepAbandonedSessions(endpoint, token, currentSessionId, fallbackProvider) {
132
139
  let swept = 0;
133
140
  for (const { sessionId, ageMs, lastActivityAt } of listStartStates()) {
@@ -139,15 +146,15 @@ async function sweepAbandonedSessions(endpoint, token, currentSessionId, fallbac
139
146
  continue;
140
147
  swept++;
141
148
  const state = readStartState(sessionId);
142
- // Nothing recoverable without the originating checkout — drop the file
143
- // 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.
144
151
  if (!state?.cwd) {
145
152
  clearStartState(sessionId);
146
153
  continue;
147
154
  }
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.
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.
151
158
  if (state.closed) {
152
159
  clearStartState(sessionId);
153
160
  continue;
@@ -155,56 +162,58 @@ async function sweepAbandonedSessions(endpoint, token, currentSessionId, fallbac
155
162
  const head = git(state.cwd, ["rev-parse", "HEAD"]);
156
163
  if (head) {
157
164
  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.
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.
161
168
  provider: readProvider(state.provider) ?? fallbackProvider,
162
169
  session_id: sessionId,
163
170
  hook_event_name: "SessionEnd",
164
171
  cwd: state.cwd,
165
172
  reason: "abandoned",
166
- // Never "now": this session died hours ago, and the receiver would
167
- // otherwise record a window spanning everything since. The receiver
168
- // treats this as a fallback and keeps a telemetry-stamped end if it
169
- // 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.
170
175
  ended_at: lastActivityAt.toISOString(),
171
- repository_url: git(state.cwd, ["remote", "get-url", "origin"]),
172
- 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)),
173
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.
174
192
  git_head_after: head,
175
193
  });
176
194
  }
177
195
  clearStartState(sessionId);
178
196
  }
179
197
  }
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
- */
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.
188
202
  function readProvider(value) {
189
203
  const match = AGENTS.find((agent) => agent.provider === value);
190
204
  return match?.provider ?? null;
191
205
  }
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
- */
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.
208
217
  function detachSelf(provider, payload) {
209
218
  const script = process.argv[1] ? resolvePath(process.argv[1]) : null;
210
219
  if (!script)
@@ -231,21 +240,21 @@ async function readStdin() {
231
240
  async function run(agent, detached) {
232
241
  const { token, endpoint } = resolveAgentCredentials(agent.id);
233
242
  if (!token || !endpoint) {
234
- // Not configured. Silence is correct: agent telemetry is opt-in, and a
235
- // 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.
236
245
  return;
237
246
  }
238
247
  const raw = await readStdin();
239
248
  if (!raw)
240
249
  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.
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.
244
253
  if (!agent.hostRunsHooksAsync && !detached) {
245
254
  if (await detachSelf(agent.provider, raw))
246
255
  return;
247
- // Could not spawn — fall through and do it inline. A slow session start
248
- // is worth more than a lost session.
256
+ // Could not spawn — fall through and do it inline. A slow session start is worth
257
+ // more than a lost one.
249
258
  }
250
259
  let payload;
251
260
  try {
@@ -260,13 +269,10 @@ async function run(agent, detached) {
260
269
  return;
261
270
  const eventName = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
262
271
  const isSessionStart = eventName === "SessionStart";
263
- // HEAD at SessionStart is the *before* SHA; HEAD at SessionEnd is the
264
- // *after*. Sending both under one field would let SessionEnd overwrite the
265
- // start SHA, collapsing the range to a single point and making
266
- // `git rev-list before..after` — the authoritative commit↔session link —
267
- // 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.
268
275
  const head = git(cwd, ["rev-parse", "HEAD"]);
269
- const repositoryUrl = git(cwd, ["remote", "get-url", "origin"]);
270
276
  const body = {
271
277
  provider: agent.provider,
272
278
  session_id: sessionId,
@@ -274,55 +280,56 @@ async function run(agent, detached) {
274
280
  cwd,
275
281
  source: typeof payload.source === "string" ? payload.source : undefined,
276
282
  reason: typeof payload.reason === "string" ? payload.reason : undefined,
277
- repository_url: repositoryUrl,
283
+ ...remotesFor(cwd),
278
284
  git_branch: git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]),
279
285
  ...(isSessionStart ? { git_head_before: head } : { git_head_after: head }),
280
286
  };
281
- // Resolve the SHA range into an actual commit list, here on the machine
282
- // that has the git object graph. The receiver cannot do this it has no
283
- // checkout so a range alone would be unusable.
284
- //
285
- // SessionStart and SessionEnd are separate processes, so the starting SHA
286
- // is stashed on disk under the session id rather than passed in the
287
- // 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.
288
291
  if (isSessionStart) {
289
- if (head)
290
- writeStartState(sessionId, { sha: head, cwd, provider: agent.provider });
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
+ });
291
304
  }
292
305
  else {
293
- const before = readStartState(sessionId)?.sha;
294
- if (before && head && before !== head) {
295
- const shas = git(cwd, [
296
- "rev-list",
297
- "--max-count=200",
298
- `${before}..${head}`,
299
- ]);
300
- if (shas)
301
- body.commit_shas = shas.split("\n").filter(Boolean);
302
- }
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;
303
311
  if (before)
304
312
  body.git_head_before = before;
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
+ // 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.
313
318
  //
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.
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.
318
322
  if (agent.hostRepeatsSessionEnd && before) {
319
323
  writeStartState(sessionId, {
320
324
  sha: before,
321
325
  cwd,
322
326
  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.
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.
326
333
  closed: true,
327
334
  });
328
335
  }
@@ -330,9 +337,8 @@ async function run(agent, detached) {
330
337
  clearStartState(sessionId);
331
338
  }
332
339
  }
333
- // transcript_path is intentionally NOT forwarded. Claude Code provides it,
334
- // and it points at the full conversation on disk exactly the content
335
- // 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.
336
342
  const result = await post(endpoint, token, body);
337
343
  recordLastRun({
338
344
  at: new Date().toISOString(),
@@ -343,21 +349,19 @@ async function run(agent, detached) {
343
349
  tokenPrefix: maskToken(token),
344
350
  status: result.status,
345
351
  error: result.error,
346
- repository: repositoryUrl ?? null,
352
+ repository: body.repository_url ?? null,
347
353
  });
348
- // After this session's own event, never before it: the sweep talks to the
349
- // network once per abandoned session and must not delay the event it is
350
- // 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.
351
356
  if (isSessionStart) {
352
357
  await sweepAbandonedSessions(endpoint, token, sessionId, agent.provider);
353
358
  }
354
359
  }
355
360
  export async function hookCommand(options) {
356
361
  try {
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.
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.
361
365
  const agent = AGENTS.find((candidate) => candidate.provider === options.provider) ??
362
366
  AGENTS[0];
363
367
  await run(agent, options.detached === true);