@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
|
@@ -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 @@
|
|
|
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":"
|
|
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
|
-
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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,
|
|
98
|
-
//
|
|
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
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
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
|
|
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
|
-
//
|
|
137
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
305
|
-
//
|
|
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
|
-
//
|
|
319
|
-
//
|
|
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.
|
|
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": {
|