@blastin-dev/clocktopus-cli 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -182,10 +182,18 @@ one that silently understates what work cost.
182
182
  ### `clocktopus agent doctor`
183
183
 
184
184
  Checks every link in the chain, per agent, and names the first one that is
185
- broken: the config file parses, the exporter is configured, both hooks are
186
- installed, the hook command actually executes, Codex has approved its hooks,
187
- the receiver accepts the token, no other file is shadowing the
188
- configuration, and whether that agent needs restarting. The repository
185
+ broken: the config file parses, the exporter is configured, every signal the
186
+ receiver reads is being sent, both hooks are installed, the hook command
187
+ actually executes, Codex has approved its hooks, the receiver accepts the
188
+ token, no other file is shadowing the configuration, and whether that agent
189
+ needs restarting.
190
+
191
+ The signals check is there for configurations written before a signal
192
+ existed. Those still hold a valid token and endpoint, so everything else
193
+ passes while one stream never leaves the machine — a Claude Code install
194
+ predating BLA-671 reports cost correctly and no sub-agent runtime at all.
195
+ Re-running `agent setup` merges the missing keys in; nothing needs removing
196
+ first. The repository
189
197
  checks — attached to a project, delivering commits — run once at the end,
190
198
  since they are facts about the repo rather than about any one agent.
191
199
 
@@ -90,6 +90,19 @@ async function agentChecks(entry) {
90
90
  : `not configured in ${state.paths.join(", ")}`,
91
91
  fix: `clocktopus agent setup --agent ${entry.agent.id}`,
92
92
  });
93
+ // A configuration written before a lane existed still has a token and an endpoint, so
94
+ // every other check here passes while one signal never leaves the machine. Only worth
95
+ // saying once the agent is configured at all — otherwise the check above covers it.
96
+ if (state.token !== null) {
97
+ checks.push({
98
+ label: "Signals",
99
+ ok: state.missingLanes.length === 0,
100
+ detail: state.missingLanes.length === 0
101
+ ? "every signal the receiver reads is configured"
102
+ : `not being sent: ${state.missingLanes.join(", ")}`,
103
+ fix: `clocktopus agent setup --agent ${entry.agent.id} — this configuration predates it`,
104
+ });
105
+ }
93
106
  // Both events are required, for different reasons: SessionStart is the only source of the
94
107
  // *before* SHA and of `cwd`, SessionEnd the only source of the exact commit list. One
95
108
  // without the other silently degrades what can be attributed.
@@ -1,2 +1,14 @@
1
1
  export declare function statusCommand(): Promise<void>;
2
+ /**
3
+ * The asymmetric-failure callouts — the reason the lanes are split.
4
+ *
5
+ * Each names the single next action, because "one lane is quiet" is only
6
+ * actionable once you know which half of the pipeline it implicates.
7
+ */
8
+ export declare function diagnose(input: {
9
+ hookAge: number | null;
10
+ metricsAge: number | null;
11
+ unattributedSessions: number;
12
+ sessions: number;
13
+ }): string[];
2
14
  //# sourceMappingURL=status.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../../../src/commands/agent/status.ts"],"names":[],"mappings":"AA2BA,wBAAsB,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CAoFnD"}
1
+ {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../../../src/commands/agent/status.ts"],"names":[],"mappings":"AA2BA,wBAAsB,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CA4FnD;AAgBD;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE;IAC9B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,oBAAoB,EAAE,MAAM,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;CAClB,GAAG,MAAM,EAAE,CAgDX"}
@@ -53,7 +53,7 @@ export async function statusCommand() {
53
53
  console.log(labelled("", `${where}${branch}`, 14));
54
54
  }
55
55
  if (status.hookLane.attributed === false) {
56
- console.log(labelled("", "not attributed to a project — see 'agent doctor'", 14));
56
+ console.log(labelled("", "this repository is not attached to a project", 14));
57
57
  }
58
58
  console.log(labelled("Spend lane", `${mark(metricsAge)} ${formatAgo(status.metricsLane.lastExportReceivedAt)}`, 14));
59
59
  if (status.metricsLane.lastModel) {
@@ -62,8 +62,11 @@ export async function statusCommand() {
62
62
  console.log("\nLast 7 days\n");
63
63
  console.log(labelled("Sessions", String(status.window.sessions)));
64
64
  console.log(labelled("Spend", microUsdToDisplay(status.window.costMicroUsd)));
65
+ // Deliberately not naming a cause. There are four, they are per-repository, and
66
+ // this figure is a `project_id IS NULL` count that knows none of them — only
67
+ // `agent doctor`, standing in the checkout, can say which one applies.
65
68
  if (status.window.unattributedSessions > 0) {
66
- console.log(labelled("Unattributed", `${status.window.unattributedSessions} session(s) no matching project`));
69
+ console.log(labelled("Unattributed", `${status.window.unattributedSessions} session(s) with no project`));
67
70
  }
68
71
  const local = readLocalSummary();
69
72
  if (local.length > 0) {
@@ -71,7 +74,12 @@ export async function statusCommand() {
71
74
  for (const line of local)
72
75
  console.log(line);
73
76
  }
74
- for (const diagnosis of diagnose({ hookAge, metricsAge })) {
77
+ for (const diagnosis of diagnose({
78
+ hookAge,
79
+ metricsAge,
80
+ unattributedSessions: status.window.unattributedSessions,
81
+ sessions: status.window.sessions,
82
+ })) {
75
83
  console.log(`\n${diagnosis}`);
76
84
  }
77
85
  }
@@ -96,7 +104,7 @@ function mark(ageMs) {
96
104
  * Each names the single next action, because "one lane is quiet" is only
97
105
  * actionable once you know which half of the pipeline it implicates.
98
106
  */
99
- function diagnose(input) {
107
+ export function diagnose(input) {
100
108
  const { hookAge, metricsAge } = input;
101
109
  const hookLive = hookAge !== null && hookAge <= STALE_AFTER_MS;
102
110
  const metricsLive = metricsAge !== null && metricsAge <= STALE_AFTER_MS;
@@ -122,6 +130,22 @@ function diagnose(input) {
122
130
  " Check the hook with 'clocktopus agent doctor'.",
123
131
  ];
124
132
  }
133
+ // Last, and only once both lanes are healthy. While the hook is silent every
134
+ // session is unattributed as a *consequence* of that, and the callout above is
135
+ // the one to act on. Reaching here means the pipeline works and the mapping does
136
+ // not, which is a different job in a different place.
137
+ if (input.unattributedSessions > 0) {
138
+ return [
139
+ `⚠ In the last 7 days, ${input.unattributedSessions} of ${input.sessions} sessions had no project.\n` +
140
+ ' Their spend is recorded and counted — it lands in the "Unattributed\n' +
141
+ ' agent work" row rather than against a client.\n' +
142
+ "\n" +
143
+ " A session takes its project from the repository it ran in, so the fix\n" +
144
+ " is a repository-to-project mapping and nothing on this machine changes\n" +
145
+ " it. Run 'clocktopus agent doctor' inside that checkout: it names which\n" +
146
+ " of the four reasons applies, and what to do about it.",
147
+ ];
148
+ }
125
149
  return [];
126
150
  }
127
151
  function readLocalSummary() {
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=status.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status.test.d.ts","sourceRoot":"","sources":["../../../../src/commands/agent/status.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { diagnose } from "./status";
3
+ /**
4
+ * The callouts `agent status` prints under the lane table.
5
+ *
6
+ * Only the ordering is interesting, and it is the whole point: the checks run
7
+ * in pipeline order so the *first* failure is the one shown, because the rest
8
+ * are usually consequences of it.
9
+ */
10
+ const LIVE = 60_000;
11
+ const STALE = 48 * 60 * 60 * 1000;
12
+ const healthy = { hookAge: LIVE, metricsAge: LIVE, sessions: 10 };
13
+ describe("diagnose", () => {
14
+ it("says nothing when both lanes are live and everything is attributed", () => {
15
+ expect(diagnose({ ...healthy, unattributedSessions: 0 })).toEqual([]);
16
+ });
17
+ it("names the repository mapping when sessions land with no project", () => {
18
+ const [callout] = diagnose({ ...healthy, unattributedSessions: 3 });
19
+ expect(callout).toContain("3 of 10");
20
+ // The actionable object. Naming only "project" is what left people
21
+ // wondering whether it meant a ticket (BLA-671).
22
+ expect(callout).toContain("repository");
23
+ expect(callout).toContain("agent doctor");
24
+ });
25
+ it("reports a silent hook instead, while one is silent", () => {
26
+ // Every session is unattributed when the hook never runs — no telemetry
27
+ // stream carries path data — so the unattributed count is a symptom here
28
+ // and the hook is the thing to fix. Showing both would send someone to
29
+ // the dashboard to fix a mapping that is already correct.
30
+ const callouts = diagnose({
31
+ hookAge: STALE,
32
+ metricsAge: LIVE,
33
+ sessions: 10,
34
+ unattributedSessions: 10,
35
+ });
36
+ expect(callouts).toHaveLength(1);
37
+ expect(callouts[0]).toContain("hook is not running");
38
+ });
39
+ it("reports a silent exporter before anything else", () => {
40
+ const callouts = diagnose({
41
+ hookAge: LIVE,
42
+ metricsAge: STALE,
43
+ sessions: 10,
44
+ unattributedSessions: 4,
45
+ });
46
+ expect(callouts).toHaveLength(1);
47
+ expect(callouts[0]).toContain("no spend is");
48
+ });
49
+ it("sends someone to doctor when nothing has ever arrived", () => {
50
+ const callouts = diagnose({
51
+ hookAge: null,
52
+ metricsAge: null,
53
+ sessions: 0,
54
+ unattributedSessions: 0,
55
+ });
56
+ expect(callouts[0]).toContain("Nothing has ever arrived");
57
+ });
58
+ });
@@ -6,6 +6,7 @@ export type AgentTelemetryState = {
6
6
  endpoint: string | null;
7
7
  /** Per-event, because a half-installed hook pair degrades silently. */
8
8
  hookCommands: Partial<Record<"SessionStart" | "SessionEnd", string>>;
9
+ missingLanes: string[];
9
10
  /** Newest mtime across the agent's config files, for the restart check. */
10
11
  modifiedAt: Date | null;
11
12
  /** Files we would write, whether or not they exist yet. */
@@ -1 +1 @@
1
- {"version":3,"file":"agents.d.ts","sourceRoot":"","sources":["../../../src/lib/agents.ts"],"names":[],"mappings":"AAyDA,MAAM,MAAM,OAAO,GAAG,QAAQ,GAAG,OAAO,GAAG,UAAU,CAAC;AAEtD,iFAAiF;AACjF,MAAM,MAAM,eAAe,GAAG,aAAa,GAAG,WAAW,GAAG,UAAU,CAAC;AAEvE,MAAM,MAAM,mBAAmB,GAAG;IAChC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,uEAAuE;IACvE,YAAY,EAAE,OAAO,CAAC,MAAM,CAAC,cAAc,GAAG,YAAY,EAAE,MAAM,CAAC,CAAC,CAAC;IACrE,2EAA2E;IAC3E,UAAU,EAAE,IAAI,GAAG,IAAI,CAAC;IACxB,2DAA2D;IAC3D,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IACzB,EAAE,EAAE,OAAO,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,eAAe,CAAC;IAC1B,8EAA8E;IAC9E,MAAM,EAAE,MAAM,CAAC;IAKf,kBAAkB,EAAE,OAAO,CAAC;IAU5B,qBAAqB,EAAE,OAAO,CAAC;IAC/B,2DAA2D;IAC3D,KAAK,IAAI,MAAM,EAAE,CAAC;IAClB,IAAI,IAAI,mBAAmB,CAAC;IAC5B,KAAK,CAAC,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,GAAG;QACtE,WAAW,EAAE,MAAM,EAAE,CAAC;KACvB,CAAC;IACF,MAAM,IAAI;QAAE,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IAGhC,cAAc,IAAI,MAAM,EAAE,CAAC;IAI3B,WAAW,CAAC,IAAI,MAAM,GAAG,IAAI,CAAC;CAC/B,CAAC;AAEF,6EAA6E;AAC7E,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,KAAK,CAKjE;AAkND,eAAO,MAAM,MAAM,EAAE,SAAS,YAAY,EAIzC,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,KAAK,EAAE,YAAY,CAAC;IACpB,wEAAwE;IACxE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,SAAS,EAAE,OAAO,CAAC;IACnB,+DAA+D;IAC/D,UAAU,EAAE,OAAO,CAAC;IACpB,kEAAkE;IAClE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B,CAAC;AAOF,wBAAgB,YAAY,IAAI,WAAW,EAAE,CAqB5C"}
1
+ {"version":3,"file":"agents.d.ts","sourceRoot":"","sources":["../../../src/lib/agents.ts"],"names":[],"mappings":"AAyDA,MAAM,MAAM,OAAO,GAAG,QAAQ,GAAG,OAAO,GAAG,UAAU,CAAC;AAEtD,iFAAiF;AACjF,MAAM,MAAM,eAAe,GAAG,aAAa,GAAG,WAAW,GAAG,UAAU,CAAC;AAEvE,MAAM,MAAM,mBAAmB,GAAG;IAChC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,uEAAuE;IACvE,YAAY,EAAE,OAAO,CAAC,MAAM,CAAC,cAAc,GAAG,YAAY,EAAE,MAAM,CAAC,CAAC,CAAC;IAIrE,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,2EAA2E;IAC3E,UAAU,EAAE,IAAI,GAAG,IAAI,CAAC;IACxB,2DAA2D;IAC3D,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IACzB,EAAE,EAAE,OAAO,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,eAAe,CAAC;IAC1B,8EAA8E;IAC9E,MAAM,EAAE,MAAM,CAAC;IAKf,kBAAkB,EAAE,OAAO,CAAC;IAU5B,qBAAqB,EAAE,OAAO,CAAC;IAC/B,2DAA2D;IAC3D,KAAK,IAAI,MAAM,EAAE,CAAC;IAClB,IAAI,IAAI,mBAAmB,CAAC;IAC5B,KAAK,CAAC,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,GAAG;QACtE,WAAW,EAAE,MAAM,EAAE,CAAC;KACvB,CAAC;IACF,MAAM,IAAI;QAAE,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IAGhC,cAAc,IAAI,MAAM,EAAE,CAAC;IAI3B,WAAW,CAAC,IAAI,MAAM,GAAG,IAAI,CAAC;CAC/B,CAAC;AAEF,6EAA6E;AAC7E,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,KAAK,CAKjE;AA0ND,eAAO,MAAM,MAAM,EAAE,SAAS,YAAY,EAIzC,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,KAAK,EAAE,YAAY,CAAC;IACpB,wEAAwE;IACxE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,SAAS,EAAE,OAAO,CAAC;IACnB,+DAA+D;IAC/D,UAAU,EAAE,OAAO,CAAC;IACpB,kEAAkE;IAClE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B,CAAC;AAOF,wBAAgB,YAAY,IAAI,WAAW,EAAE,CAqB5C"}
@@ -21,6 +21,11 @@ const claudeAdapter = {
21
21
  token: installed.env.CLOCKTOPUS_INGEST_TOKEN ?? null,
22
22
  endpoint: installed.env.CLOCKTOPUS_OTEL_ENDPOINT ?? null,
23
23
  hookCommands: installed.hookCommands,
24
+ // Added by BLA-671, so every install configured before it has a working metrics
25
+ // lane and no sub-agent runtime at all.
26
+ missingLanes: installed.env.OTEL_LOGS_EXPORTER === "otlp"
27
+ ? []
28
+ : ["sub-agent runtime"],
24
29
  modifiedAt: installed.modifiedAt,
25
30
  paths: [installed.path],
26
31
  };
@@ -63,6 +68,7 @@ const codexAdapter = {
63
68
  token: telemetry.token,
64
69
  endpoint: telemetry.endpoint,
65
70
  hookCommands: hooks.commands,
71
+ missingLanes: [],
66
72
  modifiedAt: newest([telemetry.modifiedAt, hooks.modifiedAt]),
67
73
  paths: [telemetry.path, hooks.path],
68
74
  };
@@ -158,6 +164,7 @@ const opencodeAdapter = {
158
164
  SessionEnd: plugin.config.hookArgv.join(" "),
159
165
  }
160
166
  : {},
167
+ missingLanes: [],
161
168
  modifiedAt: plugin.modifiedAt,
162
169
  paths: [plugin.path],
163
170
  };
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../src/lib/auth.ts"],"names":[],"mappings":"AA6BA,MAAM,MAAM,gBAAgB,GAAG;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,eAAe,EAAE,MAAM,CAAC;IACxB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,gBAAgB,CAAC,CA4BnE;AAED,MAAM,MAAM,UAAU,GAClB;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAC1C;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GACrB;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAC5C;IAAE,MAAM,EAAE,QAAQ,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACrC;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACtC;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzC,wBAAsB,YAAY,CAChC,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,UAAU,CAAC,CA8CrB;AAED,wBAAgB,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE/C"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../src/lib/auth.ts"],"names":[],"mappings":"AA6BA,MAAM,MAAM,gBAAgB,GAAG;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,eAAe,EAAE,MAAM,CAAC;IACxB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,gBAAgB,CAAC,CA4BnE;AAED,MAAM,MAAM,UAAU,GAClB;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAC1C;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GACrB;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAC5C;IAAE,MAAM,EAAE,QAAQ,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACrC;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACtC;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzC,wBAAsB,YAAY,CAChC,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,UAAU,CAAC,CA0DrB;AAED,wBAAgB,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE/C"}
@@ -38,6 +38,17 @@ export async function pollForToken(deviceCode, interval) {
38
38
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
39
39
  }),
40
40
  });
41
+ // The web app rate-limits `/device/token` on top of the interval this loop
42
+ // already respects, and a 429 carries Better Auth's own body — no `error`
43
+ // field — so it used to fall through to "Unexpected response" and kill a
44
+ // login that was doing nothing wrong. Back off and keep waiting instead.
45
+ if (response.status === 429) {
46
+ const retryAfter = Number(response.headers.get("x-retry-after"));
47
+ return {
48
+ status: "slow_down",
49
+ newInterval: retryAfter > 0 ? retryAfter + interval : interval + 5,
50
+ };
51
+ }
41
52
  const data = (await response.json());
42
53
  if ("access_token" in data) {
43
54
  return { status: "success", accessToken: data.access_token };
@@ -1,6 +1,6 @@
1
1
  export declare const SETTINGS_FILENAME = "settings.json";
2
2
  /** Env keys `clocktopus agent setup` owns — and the only ones it removes. */
3
- export declare const TELEMETRY_ENV_KEYS: readonly ["CLAUDE_CODE_ENABLE_TELEMETRY", "OTEL_METRICS_EXPORTER", "OTEL_EXPORTER_OTLP_PROTOCOL", "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_METRICS_INCLUDE_SESSION_ID", "OTEL_METRIC_EXPORT_INTERVAL", "CLOCKTOPUS_INGEST_TOKEN", "CLOCKTOPUS_OTEL_ENDPOINT"];
3
+ export declare const TELEMETRY_ENV_KEYS: readonly ["CLAUDE_CODE_ENABLE_TELEMETRY", "OTEL_METRICS_EXPORTER", "OTEL_LOGS_EXPORTER", "OTEL_EXPORTER_OTLP_PROTOCOL", "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_METRICS_INCLUDE_SESSION_ID", "OTEL_METRIC_EXPORT_INTERVAL", "CLOCKTOPUS_INGEST_TOKEN", "CLOCKTOPUS_OTEL_ENDPOINT"];
4
4
  export declare const METRIC_EXPORT_INTERVAL_MS = 60000;
5
5
  export declare const HOOK_TIMEOUT_SECONDS = 10;
6
6
  export type ClaudeSettings = Record<string, unknown>;
@@ -1 +1 @@
1
- {"version":3,"file":"claude-settings.d.ts","sourceRoot":"","sources":["../../../src/lib/claude-settings.ts"],"names":[],"mappings":"AAuBA,eAAO,MAAM,iBAAiB,kBAAkB,CAAC;AAEjD,6EAA6E;AAC7E,eAAO,MAAM,kBAAkB,yRAUrB,CAAC;AAKX,eAAO,MAAM,yBAAyB,QAAS,CAAC;AAMhD,eAAO,MAAM,oBAAoB,KAAK,CAAC;AAEvC,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AASrD,qBAAa,kBAAmB,SAAQ,KAAK;aACf,IAAI,EAAE,MAAM;gBAAZ,IAAI,EAAE,MAAM;CAMzC;AAED,wBAAgB,eAAe,IAAI,MAAM,CAIxC;AAED,wBAAgB,YAAY,IAAI,MAAM,CAErC;AAED,wBAAgB,YAAY,CAAC,IAAI,SAAiB,GAAG;IACnD,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,IAAI,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,cAAc,CAAC;CAC1B,CA6BA;AAKD,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,cAAc,EACxB,IAAI,SAAiB,GACpB;IAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAiB/B;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE;IACvC,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CAClB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAkBzB;AAED,QAAA,MAAM,WAAW,yCAA0C,CAAC;AAgC5D,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,cAAc,EACxB,KAAK,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAC9D,cAAc,CA0ChB;AAED,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,cAAc,GAAG;IACjE,QAAQ,EAAE,cAAc,CAAC;IACzB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,YAAY,EAAE,OAAO,CAAC;CACvB,CAmCA;AAED,wEAAwE;AACxE,wBAAgB,sBAAsB,CAAC,IAAI,SAAiB,GAAG;IAC7D,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,IAAI,GAAG,IAAI,CAAC;IACxB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5B,YAAY,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC;CACrE,CAmCA"}
1
+ {"version":3,"file":"claude-settings.d.ts","sourceRoot":"","sources":["../../../src/lib/claude-settings.ts"],"names":[],"mappings":"AAuBA,eAAO,MAAM,iBAAiB,kBAAkB,CAAC;AAEjD,6EAA6E;AAC7E,eAAO,MAAM,kBAAkB,+SAWrB,CAAC;AAKX,eAAO,MAAM,yBAAyB,QAAS,CAAC;AAMhD,eAAO,MAAM,oBAAoB,KAAK,CAAC;AAEvC,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AASrD,qBAAa,kBAAmB,SAAQ,KAAK;aACf,IAAI,EAAE,MAAM;gBAAZ,IAAI,EAAE,MAAM;CAMzC;AAED,wBAAgB,eAAe,IAAI,MAAM,CAIxC;AAED,wBAAgB,YAAY,IAAI,MAAM,CAErC;AAED,wBAAgB,YAAY,CAAC,IAAI,SAAiB,GAAG;IACnD,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,IAAI,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,cAAc,CAAC;CAC1B,CA6BA;AAKD,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,cAAc,EACxB,IAAI,SAAiB,GACpB;IAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAiB/B;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE;IACvC,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CAClB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAwBzB;AAED,QAAA,MAAM,WAAW,yCAA0C,CAAC;AAgC5D,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,cAAc,EACxB,KAAK,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAC9D,cAAc,CA0ChB;AAED,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,cAAc,GAAG;IACjE,QAAQ,EAAE,cAAc,CAAC;IACzB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,YAAY,EAAE,OAAO,CAAC;CACvB,CAmCA;AAED,wEAAwE;AACxE,wBAAgB,sBAAsB,CAAC,IAAI,SAAiB,GAAG;IAC7D,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,IAAI,GAAG,IAAI,CAAC;IACxB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5B,YAAY,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC;CACrE,CAmCA"}
@@ -16,6 +16,7 @@ export const SETTINGS_FILENAME = "settings.json";
16
16
  export const TELEMETRY_ENV_KEYS = [
17
17
  "CLAUDE_CODE_ENABLE_TELEMETRY",
18
18
  "OTEL_METRICS_EXPORTER",
19
+ "OTEL_LOGS_EXPORTER",
19
20
  "OTEL_EXPORTER_OTLP_PROTOCOL",
20
21
  "OTEL_EXPORTER_OTLP_ENDPOINT",
21
22
  "OTEL_EXPORTER_OTLP_HEADERS",
@@ -101,6 +102,12 @@ export function buildTelemetryEnv(input) {
101
102
  return {
102
103
  CLAUDE_CODE_ENABLE_TELEMETRY: "1",
103
104
  OTEL_METRICS_EXPORTER: "otlp",
105
+ // Sub-agent runtime, and nothing else (BLA-671). `claude_code.active_time.total`
106
+ // on the metric stream measures the main thread alone, so without this a session
107
+ // that delegates its work to `Task` agents reports the coordinator's time and none
108
+ // of theirs. The receiver reads exactly one event off this stream,
109
+ // `subagent_completed`, and never the ones carrying prompts or responses.
110
+ OTEL_LOGS_EXPORTER: "otlp",
104
111
  // The receiver answers protobuf with an explicit 415, but only JSON actually works.
105
112
  OTEL_EXPORTER_OTLP_PROTOCOL: "http/json",
106
113
  OTEL_EXPORTER_OTLP_ENDPOINT: endpoint,
@@ -115,6 +115,12 @@ describe("buildTelemetryEnv", () => {
115
115
  // stream, and every session loses its repository and branch.
116
116
  expect(env.OTEL_METRICS_INCLUDE_SESSION_ID).toBe("true");
117
117
  });
118
+ it("enables the log exporter, the only carrier of sub-agent runtime", () => {
119
+ // BLA-671: `claude_code.active_time.total` on the metric stream measures the
120
+ // main thread, so without this a session that delegates its work to `Task`
121
+ // agents reports the coordinator's time and none of theirs.
122
+ expect(buildTelemetryEnv(INSTALL).OTEL_LOGS_EXPORTER).toBe("otlp");
123
+ });
118
124
  it("normalises a trailing slash so paths do not double up", () => {
119
125
  const env = buildTelemetryEnv({
120
126
  ...INSTALL,
@@ -125,6 +131,13 @@ describe("buildTelemetryEnv", () => {
125
131
  });
126
132
  });
127
133
  describe("removeTelemetrySettings", () => {
134
+ it("removes the log exporter again on teardown", () => {
135
+ // Every key setup writes has to be one `agent disable` takes back, or a
136
+ // disabled agent keeps exporting to an endpoint that no longer wants it.
137
+ const installed = applyTelemetrySettings(userSettings(), INSTALL);
138
+ const { settings } = removeTelemetrySettings(installed);
139
+ expect(settings.env.OTEL_LOGS_EXPORTER).toBeUndefined();
140
+ });
128
141
  it("removes only what setup added", () => {
129
142
  const installed = applyTelemetrySettings(userSettings(), INSTALL);
130
143
  const { settings, removedEnvKeys, removedHooks } = removeTelemetrySettings(installed);
@@ -184,6 +197,18 @@ describe("reading and writing the file", () => {
184
197
  expect(installed.hookCommands.SessionStart).toBe("clocktopus agent hook");
185
198
  expect(installed.hookCommands.SessionEnd).toBe("clocktopus agent hook");
186
199
  });
200
+ it("reads back the log exporter, so an older install can be spotted", () => {
201
+ // What `agent doctor` branches on. A settings file written before BLA-671 has a
202
+ // token, an endpoint and both hooks — every other check passes — while sub-agent
203
+ // runtime never leaves the machine. This is the only signal that it is stale.
204
+ const beforeBla671 = applyTelemetrySettings(userSettings(), INSTALL);
205
+ delete beforeBla671.env.OTEL_LOGS_EXPORTER;
206
+ writeSettings(beforeBla671, path);
207
+ expect(readInstalledTelemetry(path).env.OTEL_LOGS_EXPORTER).toBeUndefined();
208
+ writeSettings(applyTelemetrySettings(beforeBla671, INSTALL), path);
209
+ // Re-running setup merges it in rather than needing a teardown first.
210
+ expect(readInstalledTelemetry(path).env.OTEL_LOGS_EXPORTER).toBe("otlp");
211
+ });
187
212
  it("reports no telemetry for a settings file that only has the user's own hooks", () => {
188
213
  writeFileSync(path, JSON.stringify(userSettings()), "utf8");
189
214
  const installed = readInstalledTelemetry(path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blastin-dev/clocktopus-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "clocktopus": "./dist/bin/clocktopus.js"
@@ -16,14 +16,14 @@
16
16
  "zod": "4.4.3"
17
17
  },
18
18
  "devDependencies": {
19
+ "@repo/eslint-config": "0.0.0",
20
+ "@repo/prettier-config": "0.1.0",
21
+ "@repo/typescript-config": "0.0.0",
19
22
  "@types/node": "22.15.3",
20
23
  "eslint": "9.37.0",
21
24
  "eslint-plugin-import-x": "^4.16.1",
22
25
  "typescript": "5.9.2",
23
- "vitest": "^4.1.5",
24
- "@repo/eslint-config": "0.0.0",
25
- "@repo/prettier-config": "0.1.0",
26
- "@repo/typescript-config": "0.0.0"
26
+ "vitest": "^4.1.5"
27
27
  },
28
28
  "prettier": "@repo/prettier-config",
29
29
  "scripts": {