@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.
- package/README.md +2 -2
- package/dist/src/commands/agent/doctor.d.ts.map +1 -1
- package/dist/src/commands/agent/doctor.js +26 -35
- package/dist/src/commands/agent/hook.d.ts.map +1 -1
- package/dist/src/commands/agent/hook.js +184 -180
- package/dist/src/commands/agent/setup.d.ts +0 -22
- package/dist/src/commands/agent/setup.d.ts.map +1 -1
- package/dist/src/commands/agent/setup.js +40 -62
- package/dist/src/lib/agent-config.d.ts +0 -33
- package/dist/src/lib/agent-config.d.ts.map +1 -1
- package/dist/src/lib/agent-config.js +15 -26
- package/dist/src/lib/agent-hook-state.d.ts +2 -6
- package/dist/src/lib/agent-hook-state.d.ts.map +1 -1
- package/dist/src/lib/agent-hook-state.js +29 -43
- package/dist/src/lib/agents.d.ts +0 -63
- package/dist/src/lib/agents.d.ts.map +1 -1
- package/dist/src/lib/agents.js +19 -26
- package/dist/src/lib/auth.d.ts.map +1 -1
- package/dist/src/lib/auth.js +11 -0
- package/dist/src/lib/claude-settings.d.ts +0 -36
- package/dist/src/lib/claude-settings.d.ts.map +1 -1
- package/dist/src/lib/claude-settings.js +37 -62
- package/dist/src/lib/codex-config.d.ts +0 -79
- package/dist/src/lib/codex-config.d.ts.map +1 -1
- package/dist/src/lib/codex-config.js +74 -116
- package/dist/src/lib/declared-commits.d.ts +43 -0
- package/dist/src/lib/declared-commits.d.ts.map +1 -0
- package/dist/src/lib/declared-commits.js +114 -0
- package/dist/src/lib/declared-commits.test.d.ts +2 -0
- package/dist/src/lib/declared-commits.test.d.ts.map +1 -0
- package/dist/src/lib/declared-commits.test.js +129 -0
- package/dist/src/lib/git-remotes.d.ts +9 -0
- package/dist/src/lib/git-remotes.d.ts.map +1 -0
- package/dist/src/lib/git-remotes.js +50 -0
- package/dist/src/lib/git-remotes.test.d.ts +2 -0
- package/dist/src/lib/git-remotes.test.d.ts.map +1 -0
- package/dist/src/lib/git-remotes.test.js +52 -0
- package/dist/src/lib/opencode-config.d.ts +0 -85
- package/dist/src/lib/opencode-config.d.ts.map +1 -1
- package/dist/src/lib/opencode-config.js +72 -112
- 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
|
-
|
|
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":"
|
|
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
|
-
//
|
|
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,
|
|
95
|
-
//
|
|
96
|
-
//
|
|
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
|
-
//
|
|
114
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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":"
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
150
|
-
//
|
|
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
|
-
//
|
|
160
|
-
//
|
|
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
|
|
167
|
-
//
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
243
|
-
//
|
|
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
|
-
//
|
|
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
|
|
264
|
-
//
|
|
265
|
-
//
|
|
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
|
-
|
|
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
|
|
282
|
-
//
|
|
283
|
-
//
|
|
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
|
-
|
|
290
|
-
|
|
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
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
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
|
|
315
|
-
//
|
|
316
|
-
// from
|
|
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
|
-
//
|
|
324
|
-
// it
|
|
325
|
-
//
|
|
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
|
|
334
|
-
//
|
|
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:
|
|
352
|
+
repository: body.repository_url ?? null,
|
|
347
353
|
});
|
|
348
|
-
// After this session's own event, never before it: the sweep talks to the
|
|
349
|
-
//
|
|
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
|
-
//
|
|
359
|
-
//
|
|
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);
|