@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
@@ -0,0 +1,50 @@
1
+ // Every remote a checkout has, for attributing the session to a repository.
2
+ //
3
+ // `origin` is a naming convention, not evidence. On a fork it names the developer's
4
+ // own copy while the webhooks — and so every commit row — arrive under the upstream,
5
+ // which left those sessions attributed to a repository nothing else in the system
6
+ // ever mentions (BLA-598). The receiver decides which one wins; the hook's job is to
7
+ // report them all.
8
+ /** Ceiling matching `AgentSessionHookSchema`. A checkout with more is not a real one. */
9
+ const MAX_REMOTES = 20;
10
+ /**
11
+ * Parses `git remote -v` into a deduplicated URL list, `origin` first.
12
+ *
13
+ * Order is load-bearing in one narrow way: the first entry is what a receiver older
14
+ * than BLA-598 reads as the only remote, so keeping `origin` there preserves the
15
+ * previous behaviour exactly.
16
+ */
17
+ export function parseGitRemotes(output) {
18
+ if (!output?.trim())
19
+ return [];
20
+ const byName = new Map();
21
+ for (const line of output.split("\n")) {
22
+ // `name\turl (fetch|push)` — push URLs can differ from fetch ones, and both are
23
+ // worth reporting: a fork often fetches upstream and pushes to itself.
24
+ const match = line.trim().match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/);
25
+ if (!match)
26
+ continue;
27
+ const [, name, url, direction] = match;
28
+ if (!name || !url)
29
+ continue;
30
+ const key = `${name}:${direction}`;
31
+ if (!byName.has(key))
32
+ byName.set(key, url);
33
+ }
34
+ const entries = [...byName.entries()];
35
+ const ordered = [
36
+ ...entries.filter(([key]) => key.startsWith("origin:")),
37
+ ...entries.filter(([key]) => !key.startsWith("origin:")),
38
+ ];
39
+ const seen = new Set();
40
+ const urls = [];
41
+ for (const [, url] of ordered) {
42
+ if (seen.has(url))
43
+ continue;
44
+ seen.add(url);
45
+ urls.push(url);
46
+ if (urls.length === MAX_REMOTES)
47
+ break;
48
+ }
49
+ return urls;
50
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=git-remotes.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"git-remotes.test.d.ts","sourceRoot":"","sources":["../../../src/lib/git-remotes.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseGitRemotes } from "./git-remotes.js";
3
+ describe("parseGitRemotes", () => {
4
+ it("keeps origin first, so an older receiver reading only the first entry is unchanged", () => {
5
+ const urls = parseGitRemotes([
6
+ "upstream\tgit@github.com:acme-co/portal.git (fetch)",
7
+ "upstream\tgit@github.com:acme-co/portal.git (push)",
8
+ "origin\tgit@github.com:jordan/portal.git (fetch)",
9
+ "origin\tgit@github.com:jordan/portal.git (push)",
10
+ ].join("\n"));
11
+ expect(urls[0]).toBe("git@github.com:jordan/portal.git");
12
+ expect(urls).toContain("git@github.com:acme-co/portal.git");
13
+ });
14
+ it("collapses the fetch and push lines of one remote", () => {
15
+ const urls = parseGitRemotes([
16
+ "origin\tgit@github.com:acme-co/portal.git (fetch)",
17
+ "origin\tgit@github.com:acme-co/portal.git (push)",
18
+ ].join("\n"));
19
+ expect(urls).toEqual(["git@github.com:acme-co/portal.git"]);
20
+ });
21
+ /** A fork often fetches from upstream and pushes to itself under one remote name. */
22
+ it("keeps both URLs when a remote's push target differs from its fetch", () => {
23
+ const urls = parseGitRemotes([
24
+ "origin\tgit@github.com:acme-co/portal.git (fetch)",
25
+ "origin\tgit@github.com:jordan/portal.git (push)",
26
+ ].join("\n"));
27
+ expect(urls).toEqual([
28
+ "git@github.com:acme-co/portal.git",
29
+ "git@github.com:jordan/portal.git",
30
+ ]);
31
+ });
32
+ it("returns nothing for a checkout with no remotes", () => {
33
+ expect(parseGitRemotes("")).toEqual([]);
34
+ expect(parseGitRemotes(undefined)).toEqual([]);
35
+ });
36
+ it("works when no remote is called origin", () => {
37
+ const urls = parseGitRemotes("github\tgit@github.com:acme-co/portal.git (fetch)");
38
+ expect(urls).toEqual(["git@github.com:acme-co/portal.git"]);
39
+ });
40
+ it("ignores lines that are not remote entries", () => {
41
+ const urls = parseGitRemotes([
42
+ "origin\tgit@github.com:acme-co/portal.git (fetch)",
43
+ "warning: something else entirely",
44
+ "",
45
+ ].join("\n"));
46
+ expect(urls).toEqual(["git@github.com:acme-co/portal.git"]);
47
+ });
48
+ it("caps a pathological remote list", () => {
49
+ const lines = Array.from({ length: 40 }, (_, i) => `r${i}\tgit@github.com:acme-co/repo-${i}.git (fetch)`);
50
+ expect(parseGitRemotes(lines.join("\n"))).toHaveLength(20);
51
+ });
52
+ });
@@ -1,104 +1,19 @@
1
- /**
2
- * Installs and reads back the OpenCode plugin that reports to Clocktopus.
3
- *
4
- * OpenCode is the odd one out. Claude Code and Codex both expose a *hook*
5
- * — a command they run with JSON on stdin — so wiring them up is a matter
6
- * of editing config. OpenCode instead exposes a plugin API: JavaScript
7
- * loaded into its own process, handed a stream of typed events. So the
8
- * integration is a file of ours rather than a config entry pointing at the
9
- * CLI.
10
- *
11
- * The install is a single file. OpenCode auto-loads everything in
12
- * `<config dir>/plugin/`, verified against 1.18.18, so nothing has to be
13
- * added to `opencode.json` — which is worth having: that file is the
14
- * user's model, provider and permission configuration, and not touching it
15
- * removes a whole class of ways to break their setup. Uninstalling is
16
- * deleting the file.
17
- *
18
- * ## Why a plugin and not OpenCode's own OpenTelemetry
19
- *
20
- * OpenCode has `experimental.openTelemetry`, which exports OTLP/JSON traces
21
- * to `OTEL_EXPORTER_OTLP_ENDPOINT` — on the face of it exactly what we
22
- * want, and a one-line install. It was measured and rejected:
23
- *
24
- * - **It ships the conversation.** The AI SDK spans carry `ai.prompt`,
25
- * `ai.prompt.messages` and `ai.response.text` — the system prompt, every
26
- * user message and the model's replies, verbatim, with no switch to turn
27
- * them off. Codex's log stream leaks tool output; this leaks everything.
28
- * - **It is enormous.** One prompt produced 230KB, almost all of it
29
- * OpenCode's internal spans — SQLite queries, file reads, lock
30
- * acquisitions — with the AI spans a rounding error inside it.
31
- * - **It carries no cost and no repository**, so it could not do the one
32
- * job the feature exists for.
33
- *
34
- * The plugin sees a better source than the traces do: `AssistantMessage`
35
- * carries `cost`, a full token breakdown, the model, and `path.cwd`. It
36
- * sends numbers and identifiers, and nothing a person wrote.
37
- */
38
1
  export declare const OPENCODE_PLUGIN_FILENAME = "clocktopus.js";
39
- /**
40
- * Bumped whenever `buildOpencodePlugin` changes what it emits.
41
- *
42
- * OpenCode is the only agent whose integration is *generated source* rather
43
- * than a command string in a config file. Claude Code and Codex hold
44
- * `clocktopus agent hook …`, which means whatever the installed CLI means,
45
- * so upgrading the CLI upgrades them. This plugin does not: the file on
46
- * disk stays exactly as the CLI that wrote it left it, and a fix shipped to
47
- * the plugin body reaches nobody until they re-run `agent setup`.
48
- *
49
- * Stamping the generation is what makes that visible — `agent doctor`
50
- * compares this number against the file and says so when they differ.
51
- * Nothing auto-rewrites the file: it holds a token, and a command that
52
- * quietly rewrites credentials is worse than one that tells you to.
53
- *
54
- * A plugin written before the stamp existed parses as `null`, which reads
55
- * as stale — correct, since it predates every version that has one.
56
- */
57
2
  export declare const OPENCODE_PLUGIN_VERSION = 1;
58
3
  export declare function opencodeConfigDir(): string;
59
4
  export declare function opencodePluginPath(): string;
60
5
  export type OpencodePluginConfig = {
61
6
  endpoint: string;
62
7
  token: string;
63
- /**
64
- * The hook as argv, not as a command line.
65
- *
66
- * `resolveHookCommand` returns a shell-quoted string because that is what
67
- * `settings.json` and `hooks.json` want — their hosts run it through a
68
- * shell. This plugin spawns it directly, so it needs the arguments
69
- * already separated: splitting the string on spaces would tear a quoted
70
- * path in two the moment anyone installs Node somewhere with a space in
71
- * it, and the failure would be silent.
72
- */
73
8
  hookArgv: string[];
74
9
  };
75
- /**
76
- * Splits a shell-quoted command line into argv.
77
- *
78
- * Only as clever as `resolveHookCommand` is: double quotes around
79
- * whitespace, nothing else. It is fed that function's output, never a
80
- * user's shell.
81
- */
82
10
  export declare function splitCommandLine(command: string): string[];
83
- /**
84
- * The plugin source, with this machine's configuration baked into one line.
85
- *
86
- * Generated rather than shipped as a file because the token has to be in
87
- * it: OpenCode gives a plugin no way to read our environment, so the
88
- * credentials live where every other agent's do — in that agent's config,
89
- * written 0600.
90
- */
91
11
  export declare function buildOpencodePlugin(config: OpencodePluginConfig): string;
92
12
  export declare function readOpencodePlugin(path?: string): {
93
13
  path: string;
94
14
  exists: boolean;
95
15
  modifiedAt: Date | null;
96
16
  config: OpencodePluginConfig | null;
97
- /**
98
- * The generation that wrote this file, or null when it carries no stamp —
99
- * either a plugin from before stamping existed, or one hand-edited past
100
- * recognition. Both mean the same thing to the caller: not current.
101
- */
102
17
  version: number | null;
103
18
  };
104
19
  export declare function writeOpencodePlugin(source: string, path?: string): {
@@ -1 +1 @@
1
- {"version":3,"file":"opencode-config.d.ts","sourceRoot":"","sources":["../../../src/lib/opencode-config.ts"],"names":[],"mappings":"AAaA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,eAAO,MAAM,wBAAwB,kBAAkB,CAAC;AAQxD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,uBAAuB,IAAI,CAAC;AAEzC,wBAAgB,iBAAiB,IAAI,MAAM,CAK1C;AAED,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAED,MAAM,MAAM,oBAAoB,GAAG;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;;;OASG;IACH,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAI1D;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,oBAAoB,GAAG,MAAM,CAqLxE;AAED,wBAAgB,kBAAkB,CAAC,IAAI,SAAuB,GAAG;IAC/D,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,IAAI,GAAG,IAAI,CAAC;IACxB,MAAM,EAAE,oBAAoB,GAAG,IAAI,CAAC;IACpC;;;;OAIG;IACH,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB,CA+CA;AAED,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,MAAM,EACd,IAAI,SAAuB,GAC1B;IAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAiB/B;AAED,wBAAgB,oBAAoB,CAAC,IAAI,SAAuB,GAAG,OAAO,CAIzE"}
1
+ {"version":3,"file":"opencode-config.d.ts","sourceRoot":"","sources":["../../../src/lib/opencode-config.ts"],"names":[],"mappings":"AAsCA,eAAO,MAAM,wBAAwB,kBAAkB,CAAC;AAoBxD,eAAO,MAAM,uBAAuB,IAAI,CAAC;AAEzC,wBAAgB,iBAAiB,IAAI,MAAM,CAK1C;AAED,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAED,MAAM,MAAM,oBAAoB,GAAG;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IAOd,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB,CAAC;AAIF,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAI1D;AAMD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,oBAAoB,GAAG,MAAM,CA0KxE;AAED,wBAAgB,kBAAkB,CAAC,IAAI,SAAuB,GAAG;IAC/D,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,IAAI,GAAG,IAAI,CAAC;IACxB,MAAM,EAAE,oBAAoB,GAAG,IAAI,CAAC;IAIpC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB,CA8CA;AAED,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,MAAM,EACd,IAAI,SAAuB,GAC1B;IAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAiB/B;AAED,wBAAgB,oBAAoB,CAAC,IAAI,SAAuB,GAAG,OAAO,CAIzE"}
@@ -1,66 +1,47 @@
1
1
  import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
- /**
5
- * Installs and reads back the OpenCode plugin that reports to Clocktopus.
6
- *
7
- * OpenCode is the odd one out. Claude Code and Codex both expose a *hook*
8
- * a command they run with JSON on stdin so wiring them up is a matter
9
- * of editing config. OpenCode instead exposes a plugin API: JavaScript
10
- * loaded into its own process, handed a stream of typed events. So the
11
- * integration is a file of ours rather than a config entry pointing at the
12
- * CLI.
13
- *
14
- * The install is a single file. OpenCode auto-loads everything in
15
- * `<config dir>/plugin/`, verified against 1.18.18, so nothing has to be
16
- * added to `opencode.json` which is worth having: that file is the
17
- * user's model, provider and permission configuration, and not touching it
18
- * removes a whole class of ways to break their setup. Uninstalling is
19
- * deleting the file.
20
- *
21
- * ## Why a plugin and not OpenCode's own OpenTelemetry
22
- *
23
- * OpenCode has `experimental.openTelemetry`, which exports OTLP/JSON traces
24
- * to `OTEL_EXPORTER_OTLP_ENDPOINT` — on the face of it exactly what we
25
- * want, and a one-line install. It was measured and rejected:
26
- *
27
- * - **It ships the conversation.** The AI SDK spans carry `ai.prompt`,
28
- * `ai.prompt.messages` and `ai.response.text` — the system prompt, every
29
- * user message and the model's replies, verbatim, with no switch to turn
30
- * them off. Codex's log stream leaks tool output; this leaks everything.
31
- * - **It is enormous.** One prompt produced 230KB, almost all of it
32
- * OpenCode's internal spans — SQLite queries, file reads, lock
33
- * acquisitions — with the AI spans a rounding error inside it.
34
- * - **It carries no cost and no repository**, so it could not do the one
35
- * job the feature exists for.
36
- *
37
- * The plugin sees a better source than the traces do: `AssistantMessage`
38
- * carries `cost`, a full token breakdown, the model, and `path.cwd`. It
39
- * sends numbers and identifiers, and nothing a person wrote.
40
- */
4
+ // Installs and reads back the OpenCode plugin that reports to Clocktopus.
5
+ //
6
+ // OpenCode is the odd one out: Claude Code and Codex expose a *hook* — a command run
7
+ // with JSON on stdin while OpenCode exposes a plugin API, JavaScript loaded into
8
+ // its own process. So the integration is a file of ours rather than a config entry.
9
+ //
10
+ // The install is a single file. OpenCode auto-loads everything in `<config
11
+ // dir>/plugin/` (verified against 1.18.18), so `opencode.json` is never touched
12
+ // worth having, since that file is the user's model, provider and permission config.
13
+ // Uninstalling is deleting the file.
14
+ //
15
+ // Not OpenCode's own `experimental.openTelemetry`, which exports OTLP/JSON traces and
16
+ // looks like a one-line install. It was measured and rejected:
17
+ //
18
+ // - It ships the conversation. The AI SDK spans carry `ai.prompt`,
19
+ // `ai.prompt.messages` and `ai.response.text` verbatim, with no switch to turn
20
+ // them off.
21
+ // - It is enormous: one prompt produced 230KB, almost all OpenCode's internal spans.
22
+ // - It carries no cost and no repository, so it could not do the one job the feature
23
+ // exists for.
24
+ //
25
+ // The plugin sees a better source: `AssistantMessage` carries `cost`, a full token
26
+ // breakdown, the model, and `path.cwd`. It sends numbers and identifiers, and
27
+ // nothing a person wrote.
41
28
  export const OPENCODE_PLUGIN_FILENAME = "clocktopus.js";
42
29
  /** The line `readOpencodeTelemetry` parses back out of the plugin. */
43
30
  const CONFIG_MARKER = "const CLOCKTOPUS = ";
44
31
  /** Prefix of the line carrying the generation stamp. */
45
32
  const VERSION_MARKER = "// clocktopus-plugin-version: ";
46
- /**
47
- * Bumped whenever `buildOpencodePlugin` changes what it emits.
48
- *
49
- * OpenCode is the only agent whose integration is *generated source* rather
50
- * than a command string in a config file. Claude Code and Codex hold
51
- * `clocktopus agent hook …`, which means whatever the installed CLI means,
52
- * so upgrading the CLI upgrades them. This plugin does not: the file on
53
- * disk stays exactly as the CLI that wrote it left it, and a fix shipped to
54
- * the plugin body reaches nobody until they re-run `agent setup`.
55
- *
56
- * Stamping the generation is what makes that visible — `agent doctor`
57
- * compares this number against the file and says so when they differ.
58
- * Nothing auto-rewrites the file: it holds a token, and a command that
59
- * quietly rewrites credentials is worse than one that tells you to.
60
- *
61
- * A plugin written before the stamp existed parses as `null`, which reads
62
- * as stale — correct, since it predates every version that has one.
63
- */
33
+ // Bumped whenever `buildOpencodePlugin` changes what it emits.
34
+ //
35
+ // OpenCode is the only agent whose integration is generated source rather than a
36
+ // command string. Claude Code and Codex hold `clocktopus agent hook …`, which means
37
+ // whatever the installed CLI means, so upgrading the CLI upgrades them. This plugin
38
+ // does not: a fix reaches nobody until they re-run `agent setup`.
39
+ //
40
+ // Stamping the generation makes that visible `agent doctor` compares this number
41
+ // against the file. Nothing auto-rewrites it: it holds a token, and a command that
42
+ // quietly rewrites credentials is worse than one that tells you to.
43
+ //
44
+ // A plugin written before the stamp parses as `null`, which reads as stale.
64
45
  export const OPENCODE_PLUGIN_VERSION = 1;
65
46
  export function opencodeConfigDir() {
66
47
  if (process.env.OPENCODE_CONFIG_DIR)
@@ -72,42 +53,28 @@ export function opencodeConfigDir() {
72
53
  export function opencodePluginPath() {
73
54
  return join(opencodeConfigDir(), "plugin", OPENCODE_PLUGIN_FILENAME);
74
55
  }
75
- /**
76
- * Splits a shell-quoted command line into argv.
77
- *
78
- * Only as clever as `resolveHookCommand` is: double quotes around
79
- * whitespace, nothing else. It is fed that function's output, never a
80
- * user's shell.
81
- */
56
+ // Only as clever as `resolveHookCommand` is: double quotes around whitespace,
57
+ // nothing else. It is fed that function's output, never a user's shell.
82
58
  export function splitCommandLine(command) {
83
59
  return (command.match(/"[^"]*"|\S+/g) ?? []).map((part) => part.startsWith('"') && part.endsWith('"') ? part.slice(1, -1) : part);
84
60
  }
85
- /**
86
- * The plugin source, with this machine's configuration baked into one line.
87
- *
88
- * Generated rather than shipped as a file because the token has to be in
89
- * it: OpenCode gives a plugin no way to read our environment, so the
90
- * credentials live where every other agent's do — in that agent's config,
91
- * written 0600.
92
- */
61
+ // The plugin source, with this machine's configuration baked into one line.
62
+ // Generated rather than shipped because the token has to be in it: OpenCode gives a
63
+ // plugin no way to read our environment, so the credentials live where every other
64
+ // agent's do in that agent's config, written 0600.
93
65
  export function buildOpencodePlugin(config) {
94
66
  return `// Generated by 'clocktopus agent setup'. Edits will be overwritten.
95
67
  ${VERSION_MARKER}${OPENCODE_PLUGIN_VERSION}
96
68
  //
97
- // Reports what OpenCode sessions cost to Clocktopus. Two channels, for the
98
- // same reason every other agent has two: spend and repository context come
99
- // from different places and neither is much use alone.
69
+ // Reports what OpenCode sessions cost to Clocktopus. Two channels, because spend and
70
+ // repository context come from different places and neither is much use alone:
100
71
  //
101
- // spend POSTed straight to the receiver as OTel GenAI spans, because
102
- // it happens per assistant message and spawning a process each
103
- // time would be absurd.
104
- // context handed to 'clocktopus agent hook', which already knows how to
105
- // resolve a git remote, diff a session's commit range and sweep
106
- // sessions that died — none of which is worth reimplementing in
107
- // here.
72
+ // spend POSTed straight to the receiver as OTel GenAI spans, because it happens
73
+ // per assistant message and spawning a process each time would be absurd.
74
+ // context handed to 'clocktopus agent hook', which already resolves git remotes,
75
+ // diffs a session's commit range and sweeps dead sessions.
108
76
  //
109
- // Nothing a person wrote is read: not the prompt, not the model's reply,
110
- // not tool output. Only counts, identifiers and timings.
77
+ // Nothing a person wrote is read: not the prompt, not the reply, not tool output.
111
78
  import { spawn } from "node:child_process";
112
79
 
113
80
  ${CONFIG_MARKER}${JSON.stringify(config)};
@@ -132,10 +99,9 @@ function spans(message, version) {
132
99
 
133
100
  return [
134
101
  {
135
- // Carries the turn's duration and nothing else. Clocktopus takes
136
- // active time from this span alone, so emitting one per assistant
137
- // message is what makes the total "time the agent was working"
138
- // rather than "time the window was open".
102
+ // Carries the turn's duration and nothing else. Clocktopus takes active time from
103
+ // this span alone, so one per assistant message is what makes the total "time the
104
+ // agent was working" rather than "time the window was open".
139
105
  name: "gen_ai.client.session",
140
106
  startTimeUnixNano: nano(started),
141
107
  endTimeUnixNano: nano(ended),
@@ -149,16 +115,13 @@ function spans(message, version) {
149
115
  ...shared,
150
116
  attr("gen_ai.request.model", message.modelID),
151
117
  attr("gen_ai.provider.name", message.providerID),
152
- // OpenCode reports input already net of the cached prefix, so these
153
- // buckets add up rather than overlap — the opposite of Codex, which
154
- // reports input inclusive and has to be subtracted.
118
+ // OpenCode reports input already net of the cached prefix, so these buckets add up
119
+ // rather than overlap — the opposite of Codex, which reports input inclusive.
155
120
  attr("gen_ai.usage.input_tokens", message.tokens.input),
156
- // Reasoning tokens are output tokens that were not shown. OpenCode
157
- // reports them separately and Clocktopus has no bucket for them, so
158
- // they are folded in here rather than dropped: its own totals treat
159
- // them this way (input + cache.read + output + reasoning == total)
160
- // and it prices them at the output rate, so leaving them out would
161
- // under-report tokens against a cost that already includes them.
121
+ // Reasoning tokens are output tokens that were not shown. OpenCode reports them
122
+ // separately and Clocktopus has no bucket for them, so they are folded in: its own
123
+ // totals treat them this way and it prices them at the output rate, so leaving them
124
+ // out would under-report tokens against a cost that already includes them.
162
125
  attr(
163
126
  "gen_ai.usage.output_tokens",
164
127
  message.tokens.output + (message.tokens.reasoning ?? 0),
@@ -168,8 +131,8 @@ function spans(message, version) {
168
131
  "gen_ai.usage.cache_creation_input_tokens",
169
132
  message.tokens.cache.write,
170
133
  ),
171
- // Priced by OpenCode from the models.dev rate card, so Clocktopus
172
- // records it as an estimate, never as a settled bill.
134
+ // Priced by OpenCode from the models.dev rate card, so Clocktopus records it as an
135
+ // estimate, never as a settled bill.
173
136
  attr("gen_ai.usage.cost", message.cost),
174
137
  ],
175
138
  },
@@ -249,11 +212,10 @@ export const ClocktopusPlugin = async ({ directory, worktree }) => {
249
212
  return;
250
213
  }
251
214
 
252
- // OpenCode has no "session ended" event — 'idle' is what it emits
253
- // when the agent stops working and waits for a human. Treating that
254
- // as the end keeps the session's end time tracking the last moment it
255
- // was actually busy, which is the bound commit attribution needs, and
256
- // re-sending it on a later turn is harmless: the receiver merges.
215
+ // OpenCode has no "session ended" event — 'idle' is what it emits when the agent
216
+ // stops and waits for a human. Treating that as the end keeps the session's end time
217
+ // tracking the last moment it was busy, which is the bound commit attribution needs;
218
+ // re-sending on a later turn is harmless, since the receiver merges.
257
219
  if (event.type === "session.idle") {
258
220
  notify("SessionEnd", event.properties.sessionID, root);
259
221
  return;
@@ -261,9 +223,8 @@ export const ClocktopusPlugin = async ({ directory, worktree }) => {
261
223
 
262
224
  if (event.type === "message.updated") {
263
225
  const message = event.properties.info;
264
- // Assistant messages only, and only once finished — an in-flight
265
- // message is republished on every token, with counts that are not
266
- // final.
226
+ // Assistant messages only, and only once finished — an in-flight message is
227
+ // republished on every token, with counts that are not final.
267
228
  if (message?.role !== "assistant" || !message.time?.completed) return;
268
229
  await report(message, version);
269
230
  }
@@ -300,10 +261,9 @@ export function readOpencodePlugin(path = opencodePluginPath()) {
300
261
  }
301
262
  }
302
263
  catch {
303
- // A plugin we cannot read the configuration out of is reported as
304
- // unconfigured, which sends the user to `setup` — the right answer
305
- // whether it was hand-edited or written by a version that has since
306
- // changed shape.
264
+ // A plugin we cannot read the configuration out of is reported as unconfigured,
265
+ // which sends the user to `setup` — the right answer whether it was hand-edited or
266
+ // written by a version that has since changed shape.
307
267
  }
308
268
  return { path, exists: true, modifiedAt, config, version };
309
269
  }
@@ -314,9 +274,9 @@ export function writeOpencodePlugin(source, path = opencodePluginPath()) {
314
274
  backupPath = `${path}.clocktopus-backup`;
315
275
  copyFileSync(path, backupPath);
316
276
  }
317
- // Rename rather than write in place: OpenCode loads every file in this
318
- // directory at startup, and a half-written one would be a syntax error
319
- // that takes the whole plugin system down with it.
277
+ // Rename rather than write in place: OpenCode loads every file in this directory at
278
+ // startup, and a half-written one would be a syntax error that takes the whole
279
+ // plugin system down with it.
320
280
  const temporaryPath = `${path}.clocktopus-tmp`;
321
281
  writeFileSync(temporaryPath, source, { encoding: "utf8", mode: 0o600 });
322
282
  renameSync(temporaryPath, path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blastin-dev/clocktopus-cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
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": {