@blastin-dev/clocktopus-cli 0.1.4 → 0.2.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 +163 -5
- package/dist/src/commands/agent/disable.d.ts +21 -0
- package/dist/src/commands/agent/disable.d.ts.map +1 -0
- package/dist/src/commands/agent/disable.js +106 -0
- package/dist/src/commands/agent/doctor.d.ts +2 -0
- package/dist/src/commands/agent/doctor.d.ts.map +1 -0
- package/dist/src/commands/agent/doctor.js +306 -0
- package/dist/src/commands/agent/hook.d.ts +5 -0
- package/dist/src/commands/agent/hook.d.ts.map +1 -0
- package/dist/src/commands/agent/hook.js +368 -0
- package/dist/src/commands/agent/setup.d.ts +28 -0
- package/dist/src/commands/agent/setup.d.ts.map +1 -0
- package/dist/src/commands/agent/setup.js +333 -0
- package/dist/src/commands/agent/status.d.ts +2 -0
- package/dist/src/commands/agent/status.d.ts.map +1 -0
- package/dist/src/commands/agent/status.js +182 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +51 -2
- package/dist/src/lib/agent-config.d.ts +56 -0
- package/dist/src/lib/agent-config.d.ts.map +1 -0
- package/dist/src/lib/agent-config.js +168 -0
- package/dist/src/lib/agent-hook-state.d.ts +44 -0
- package/dist/src/lib/agent-hook-state.d.ts.map +1 -0
- package/dist/src/lib/agent-hook-state.js +155 -0
- package/dist/src/lib/agent-receiver.d.ts +26 -0
- package/dist/src/lib/agent-receiver.d.ts.map +1 -0
- package/dist/src/lib/agent-receiver.js +44 -0
- package/dist/src/lib/agents.d.ts +115 -0
- package/dist/src/lib/agents.d.ts.map +1 -0
- package/dist/src/lib/agents.js +245 -0
- package/dist/src/lib/claude-settings.d.ts +82 -0
- package/dist/src/lib/claude-settings.d.ts.map +1 -0
- package/dist/src/lib/claude-settings.js +271 -0
- package/dist/src/lib/claude-settings.test.d.ts +2 -0
- package/dist/src/lib/claude-settings.test.d.ts.map +1 -0
- package/dist/src/lib/claude-settings.test.js +193 -0
- package/dist/src/lib/codex-config.d.ts +166 -0
- package/dist/src/lib/codex-config.d.ts.map +1 -0
- package/dist/src/lib/codex-config.js +441 -0
- package/dist/src/lib/codex-config.test.d.ts +2 -0
- package/dist/src/lib/codex-config.test.d.ts.map +1 -0
- package/dist/src/lib/codex-config.test.js +359 -0
- package/dist/src/lib/config.d.ts +23 -0
- package/dist/src/lib/config.d.ts.map +1 -1
- package/dist/src/lib/config.js +14 -0
- package/dist/src/lib/format.d.ts +6 -0
- package/dist/src/lib/format.d.ts.map +1 -0
- package/dist/src/lib/format.js +19 -0
- package/dist/src/lib/git.d.ts +3 -0
- package/dist/src/lib/git.d.ts.map +1 -0
- package/dist/src/lib/git.js +30 -0
- package/dist/src/lib/opencode-config.d.ts +108 -0
- package/dist/src/lib/opencode-config.d.ts.map +1 -0
- package/dist/src/lib/opencode-config.js +330 -0
- package/dist/src/lib/opencode-config.test.d.ts +2 -0
- package/dist/src/lib/opencode-config.test.d.ts.map +1 -0
- package/dist/src/lib/opencode-config.test.js +140 -0
- package/dist/src/lib/repo-guidance.d.ts +40 -0
- package/dist/src/lib/repo-guidance.d.ts.map +1 -0
- package/dist/src/lib/repo-guidance.js +123 -0
- package/dist/src/lib/validators.d.ts +69 -0
- package/dist/src/lib/validators.d.ts.map +1 -1
- package/dist/src/lib/validators.js +69 -0
- package/package.json +7 -4
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync, readFileSync, realpathSync, } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { delimiter, join, resolve } from "node:path";
|
|
4
|
+
import { readInstalledTelemetry, settingsPath } from "./claude-settings.js";
|
|
5
|
+
import { codexConfigPath, readCodexTelemetry } from "./codex-config.js";
|
|
6
|
+
import { opencodePluginPath, readOpencodePlugin } from "./opencode-config.js";
|
|
7
|
+
export function resolveAgentCredentials(agent = "claude") {
|
|
8
|
+
const envToken = process.env.CLOCKTOPUS_INGEST_TOKEN?.trim();
|
|
9
|
+
const envEndpoint = process.env.CLOCKTOPUS_OTEL_ENDPOINT?.trim();
|
|
10
|
+
let fileToken;
|
|
11
|
+
let fileEndpoint;
|
|
12
|
+
try {
|
|
13
|
+
if (agent === "codex") {
|
|
14
|
+
const codex = readCodexTelemetry();
|
|
15
|
+
fileToken = codex.token ?? undefined;
|
|
16
|
+
fileEndpoint = codex.endpoint ?? undefined;
|
|
17
|
+
}
|
|
18
|
+
else if (agent === "opencode") {
|
|
19
|
+
const plugin = readOpencodePlugin();
|
|
20
|
+
fileToken = plugin.config?.token ?? undefined;
|
|
21
|
+
fileEndpoint = plugin.config?.endpoint ?? undefined;
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
const installed = readInstalledTelemetry();
|
|
25
|
+
fileToken = installed.env.CLOCKTOPUS_INGEST_TOKEN;
|
|
26
|
+
fileEndpoint = installed.env.CLOCKTOPUS_OTEL_ENDPOINT;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// A malformed config file is reported properly by `setup` and `doctor`.
|
|
31
|
+
// Credential resolution must not throw — the hook calls it on every
|
|
32
|
+
// session start.
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
token: envToken || fileToken || null,
|
|
36
|
+
endpoint: (envEndpoint || fileEndpoint || null)?.replace(/\/$/, "") ?? null,
|
|
37
|
+
tokenSource: envToken ? "environment" : fileToken ? "settings" : "none",
|
|
38
|
+
endpointSource: envEndpoint
|
|
39
|
+
? "environment"
|
|
40
|
+
: fileEndpoint
|
|
41
|
+
? "settings"
|
|
42
|
+
: "none",
|
|
43
|
+
sourcePath: agent === "codex"
|
|
44
|
+
? codexConfigPath()
|
|
45
|
+
: agent === "opencode"
|
|
46
|
+
? opencodePluginPath()
|
|
47
|
+
: settingsPath(),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Masks a token for display: never print more than the stored prefix. */
|
|
51
|
+
export function maskToken(token) {
|
|
52
|
+
return token.length <= 15 ? "…" : `${token.slice(0, 15)}…`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Files that commonly export the same variables, and would be shadowed.
|
|
56
|
+
*
|
|
57
|
+
* Scanned rather than guessed at, because "your `.envrc` is being ignored"
|
|
58
|
+
* is only useful advice when it names the file. The repo-local `.envrc` is
|
|
59
|
+
* included because that is exactly how this project was dogfooded before
|
|
60
|
+
* the CLI existed.
|
|
61
|
+
*/
|
|
62
|
+
const SHELL_FILES = [
|
|
63
|
+
".bashrc",
|
|
64
|
+
".bash_profile",
|
|
65
|
+
".zshrc",
|
|
66
|
+
".zshenv",
|
|
67
|
+
".profile",
|
|
68
|
+
".config/fish/config.fish",
|
|
69
|
+
];
|
|
70
|
+
export function findShadowedExports(cwd = process.cwd()) {
|
|
71
|
+
const candidates = [
|
|
72
|
+
...SHELL_FILES.map((file) => join(homedir(), file)),
|
|
73
|
+
join(cwd, ".envrc"),
|
|
74
|
+
];
|
|
75
|
+
const found = [];
|
|
76
|
+
for (const path of candidates) {
|
|
77
|
+
let contents;
|
|
78
|
+
try {
|
|
79
|
+
if (!existsSync(path))
|
|
80
|
+
continue;
|
|
81
|
+
contents = readFileSync(path, "utf8");
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const keys = [
|
|
87
|
+
"CLOCKTOPUS_INGEST_TOKEN",
|
|
88
|
+
"CLOCKTOPUS_OTEL_ENDPOINT",
|
|
89
|
+
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
90
|
+
"OTEL_EXPORTER_OTLP_HEADERS",
|
|
91
|
+
"CLAUDE_CODE_ENABLE_TELEMETRY",
|
|
92
|
+
].filter((key) => new RegExp(`^\\s*(export\\s+)?${key}=`, "m").test(contents));
|
|
93
|
+
if (keys.length > 0)
|
|
94
|
+
found.push({ path, keys });
|
|
95
|
+
}
|
|
96
|
+
return found;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The command to write into an agent's hook configuration.
|
|
100
|
+
*
|
|
101
|
+
* Prefers the bare name, but only after confirming that `clocktopus` on
|
|
102
|
+
* PATH resolves to *this* executable. Hooks run through a shell whose PATH
|
|
103
|
+
* may differ from the interactive one, and a bare name that does not
|
|
104
|
+
* resolve there fails silently — the session runs fine and every bit of
|
|
105
|
+
* repository context is lost. An absolute path is uglier and survives that;
|
|
106
|
+
* `doctor` checks it still exists.
|
|
107
|
+
*
|
|
108
|
+
* The provider is baked into the command because the two agents send
|
|
109
|
+
* *identical* hook payloads — same field names, same event spellings, no
|
|
110
|
+
* marker of any kind. Which agent is calling can only be known from how the
|
|
111
|
+
* hook was installed.
|
|
112
|
+
*/
|
|
113
|
+
export function resolveHookCommand(provider) {
|
|
114
|
+
const script = process.argv[1] ? resolve(process.argv[1]) : null;
|
|
115
|
+
const quote = (value) => /[\s"']/.test(value) ? `"${value}"` : value;
|
|
116
|
+
const args = `agent hook --provider ${provider}`;
|
|
117
|
+
if (script && resolvesOnPath("clocktopus", script)) {
|
|
118
|
+
return {
|
|
119
|
+
command: `clocktopus ${args}`,
|
|
120
|
+
usesAbsolutePath: false,
|
|
121
|
+
binaryPath: script,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (script) {
|
|
125
|
+
// `node <script>` rather than executing the script directly: the file in
|
|
126
|
+
// a published package keeps its shebang, but a checkout may not have the
|
|
127
|
+
// executable bit, and the hook must not depend on that.
|
|
128
|
+
return {
|
|
129
|
+
command: `${quote(process.execPath)} ${quote(script)} ${args}`,
|
|
130
|
+
usesAbsolutePath: true,
|
|
131
|
+
binaryPath: script,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
command: `clocktopus ${args}`,
|
|
136
|
+
usesAbsolutePath: false,
|
|
137
|
+
binaryPath: null,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function resolvesOnPath(name, expected) {
|
|
141
|
+
const searchPath = process.env.PATH;
|
|
142
|
+
if (!searchPath)
|
|
143
|
+
return false;
|
|
144
|
+
let expectedReal;
|
|
145
|
+
try {
|
|
146
|
+
expectedReal = realpathSync(expected);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
for (const dir of searchPath.split(delimiter)) {
|
|
152
|
+
if (!dir)
|
|
153
|
+
continue;
|
|
154
|
+
const candidate = join(dir, name);
|
|
155
|
+
try {
|
|
156
|
+
accessSync(candidate, constants.X_OK);
|
|
157
|
+
if (realpathSync(candidate) === expectedReal)
|
|
158
|
+
return true;
|
|
159
|
+
// A different `clocktopus` earlier on PATH would shadow this one, so
|
|
160
|
+
// stop rather than keep looking for a match further down.
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
declare const LastRunSchema: z.ZodObject<{
|
|
3
|
+
at: z.ZodString;
|
|
4
|
+
event: z.ZodOptional<z.ZodString>;
|
|
5
|
+
provider: z.ZodOptional<z.ZodString>;
|
|
6
|
+
sessionId: z.ZodOptional<z.ZodString>;
|
|
7
|
+
endpoint: z.ZodOptional<z.ZodString>;
|
|
8
|
+
tokenPrefix: z.ZodOptional<z.ZodString>;
|
|
9
|
+
status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
10
|
+
error: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
11
|
+
repository: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
12
|
+
}, z.core.$strip>;
|
|
13
|
+
export type HookLastRun = z.infer<typeof LastRunSchema>;
|
|
14
|
+
declare const StartStateSchema: z.ZodObject<{
|
|
15
|
+
sha: z.ZodString;
|
|
16
|
+
cwd: z.ZodOptional<z.ZodString>;
|
|
17
|
+
provider: z.ZodOptional<z.ZodString>;
|
|
18
|
+
closed: z.ZodOptional<z.ZodBoolean>;
|
|
19
|
+
}, z.core.$strip>;
|
|
20
|
+
export type HookStartState = z.infer<typeof StartStateSchema>;
|
|
21
|
+
export declare function writeStartState(sessionId: string, state: {
|
|
22
|
+
sha: string;
|
|
23
|
+
cwd: string;
|
|
24
|
+
provider: string;
|
|
25
|
+
closed?: boolean;
|
|
26
|
+
}): void;
|
|
27
|
+
export declare function readStartState(sessionId: string): HookStartState | undefined;
|
|
28
|
+
export declare function clearStartState(sessionId: string): void;
|
|
29
|
+
export declare function listStartStates(): Array<{
|
|
30
|
+
sessionId: string;
|
|
31
|
+
ageMs: number;
|
|
32
|
+
/**
|
|
33
|
+
* When the state file was last touched — the newest moment this machine
|
|
34
|
+
* has evidence the session existed. The sweep sends it as the abandoned
|
|
35
|
+
* session's `ended_at` so the receiver does not stamp its own clock,
|
|
36
|
+
* hours later, as the end of a window used to attribute commits.
|
|
37
|
+
*/
|
|
38
|
+
lastActivityAt: Date;
|
|
39
|
+
}>;
|
|
40
|
+
export declare function recordLastRun(run: HookLastRun): void;
|
|
41
|
+
export declare function readLastRun(): HookLastRun | null;
|
|
42
|
+
export declare function clearHookState(): void;
|
|
43
|
+
export {};
|
|
44
|
+
//# sourceMappingURL=agent-hook-state.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-hook-state.d.ts","sourceRoot":"","sources":["../../../src/lib/agent-hook-state.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AA6BxB,QAAA,MAAM,aAAa;;;;;;;;;;iBAYjB,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAExD,QAAA,MAAM,gBAAgB;;;;;iBAoBpB,CAAC;AAEH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAK9D,wBAAgB,eAAe,CAC7B,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,GACtE,IAAI,CAWN;AAED,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS,CAgB5E;AAED,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAMvD;AAED,wBAAgB,eAAe,IAAI,KAAK,CAAC;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,cAAc,EAAE,IAAI,CAAC;CACtB,CAAC,CAsBD;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,WAAW,GAAG,IAAI,CAOpD;AAED,wBAAgB,WAAW,IAAI,WAAW,GAAG,IAAI,CAShD;AAED,wBAAgB,cAAc,IAAI,IAAI,CAOrC"}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
/**
|
|
6
|
+
* On-disk state the SessionStart/SessionEnd hook keeps between its two
|
|
7
|
+
* invocations, plus the record of what the last invocation achieved.
|
|
8
|
+
*
|
|
9
|
+
* Under the cache directory rather than the repository so it never shows up
|
|
10
|
+
* in `git status`. The path is unchanged from the standalone script this
|
|
11
|
+
* command replaced (`scripts/agent-telemetry/claude-hook.mjs`, since
|
|
12
|
+
* deleted) on purpose: a session that started under the old script must
|
|
13
|
+
* still be able to close under the new one, and a sweep must still find
|
|
14
|
+
* what the old one abandoned.
|
|
15
|
+
*/
|
|
16
|
+
const CACHE_DIR = join(homedir(), ".cache", "clocktopus");
|
|
17
|
+
const STATE_DIR = join(CACHE_DIR, "agent-sessions");
|
|
18
|
+
/**
|
|
19
|
+
* The hook's last outcome, written every run.
|
|
20
|
+
*
|
|
21
|
+
* This is the only place the pipeline reports on itself from the machine's
|
|
22
|
+
* own side. The hook must stay silent — it cannot warn, prompt or print —
|
|
23
|
+
* so instead it leaves a receipt, and `agent status` / `agent doctor` read
|
|
24
|
+
* it. Without this, a token that started returning 401 mid-week is
|
|
25
|
+
* invisible locally: the session runs normally and nothing is written
|
|
26
|
+
* anywhere the user looks.
|
|
27
|
+
*/
|
|
28
|
+
const LAST_RUN_FILE = join(CACHE_DIR, "agent-hook-last.json");
|
|
29
|
+
const LastRunSchema = z.object({
|
|
30
|
+
at: z.string(),
|
|
31
|
+
event: z.string().optional(),
|
|
32
|
+
/** Which agent's hook ran — one receipt file is shared by all of them. */
|
|
33
|
+
provider: z.string().optional(),
|
|
34
|
+
sessionId: z.string().optional(),
|
|
35
|
+
endpoint: z.string().optional(),
|
|
36
|
+
tokenPrefix: z.string().optional(),
|
|
37
|
+
/** HTTP status from the receiver, or null when the request never landed. */
|
|
38
|
+
status: z.number().nullable().optional(),
|
|
39
|
+
error: z.string().nullable().optional(),
|
|
40
|
+
repository: z.string().nullable().optional(),
|
|
41
|
+
});
|
|
42
|
+
const StartStateSchema = z.object({
|
|
43
|
+
sha: z.string(),
|
|
44
|
+
cwd: z.string().optional(),
|
|
45
|
+
/**
|
|
46
|
+
* The agent that opened this session.
|
|
47
|
+
*
|
|
48
|
+
* Recorded because the sweep runs from whichever agent starts *next*, and
|
|
49
|
+
* closing a Codex session as `claude_code` would not close it at all — it
|
|
50
|
+
* would open a second, empty session row under the wrong provider and
|
|
51
|
+
* leave the real one hanging forever.
|
|
52
|
+
*/
|
|
53
|
+
provider: z.string().optional(),
|
|
54
|
+
/**
|
|
55
|
+
* Set once the session has had a real SessionEnd, and kept only because
|
|
56
|
+
* its host can send another one — see `hostRepeatsSessionEnd`. The file
|
|
57
|
+
* still holds the starting SHA the next SessionEnd needs to diff against,
|
|
58
|
+
* but the session is already closed in the database, so the sweep must
|
|
59
|
+
* delete it rather than close it a second time with a stale HEAD.
|
|
60
|
+
*/
|
|
61
|
+
closed: z.boolean().optional(),
|
|
62
|
+
});
|
|
63
|
+
const stateFile = (sessionId) => join(STATE_DIR, `${sessionId.replace(/[^\w-]/g, "")}.sha`);
|
|
64
|
+
export function writeStartState(sessionId, state) {
|
|
65
|
+
try {
|
|
66
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
67
|
+
// `cwd` is stored alongside the SHA because the sweep runs from
|
|
68
|
+
// whatever repository the *next* session happens to start in.
|
|
69
|
+
// Resolving an abandoned session's SHA against the wrong checkout would
|
|
70
|
+
// either fail or, worse, succeed against an unrelated history.
|
|
71
|
+
writeFileSync(stateFile(sessionId), JSON.stringify(state), "utf8");
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Losing the start SHA costs the exact commit list, not the session.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function readStartState(sessionId) {
|
|
78
|
+
try {
|
|
79
|
+
const raw = readFileSync(stateFile(sessionId), "utf8").trim();
|
|
80
|
+
if (!raw)
|
|
81
|
+
return undefined;
|
|
82
|
+
try {
|
|
83
|
+
const parsed = StartStateSchema.safeParse(JSON.parse(raw));
|
|
84
|
+
return parsed.success ? parsed.data : undefined;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Files written by earlier versions hold a bare SHA. A session that
|
|
88
|
+
// started under the old format must still close correctly.
|
|
89
|
+
return { sha: raw };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function clearStartState(sessionId) {
|
|
97
|
+
try {
|
|
98
|
+
rmSync(stateFile(sessionId), { force: true });
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Best effort; a stale file is overwritten by the next session anyway.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export function listStartStates() {
|
|
105
|
+
try {
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
return readdirSync(STATE_DIR)
|
|
108
|
+
.filter((name) => name.endsWith(".sha"))
|
|
109
|
+
.flatMap((name) => {
|
|
110
|
+
try {
|
|
111
|
+
const { mtimeMs } = statSync(join(STATE_DIR, name));
|
|
112
|
+
return [
|
|
113
|
+
{
|
|
114
|
+
sessionId: name.replace(/\.sha$/, ""),
|
|
115
|
+
ageMs: now - mtimeMs,
|
|
116
|
+
lastActivityAt: new Date(mtimeMs),
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export function recordLastRun(run) {
|
|
130
|
+
try {
|
|
131
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
132
|
+
writeFileSync(LAST_RUN_FILE, JSON.stringify(run, null, 2), "utf8");
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// A missing receipt degrades diagnostics; it must never fail a session.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export function readLastRun() {
|
|
139
|
+
try {
|
|
140
|
+
const parsed = LastRunSchema.safeParse(JSON.parse(readFileSync(LAST_RUN_FILE, "utf8")));
|
|
141
|
+
return parsed.success ? parsed.data : null;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export function clearHookState() {
|
|
148
|
+
try {
|
|
149
|
+
rmSync(STATE_DIR, { recursive: true, force: true });
|
|
150
|
+
rmSync(LAST_RUN_FILE, { force: true });
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Nothing to clean up, or not ours to clean up.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Talks to the OTLP receiver directly, which is the whole point.
|
|
3
|
+
*
|
|
4
|
+
* The receiver is a different host from the web app — its own Router, its
|
|
5
|
+
* own Lambda, its own domain — so a token that works against the dashboard
|
|
6
|
+
* proves nothing about whether telemetry can reach ingest. This check is
|
|
7
|
+
* the only local way to tell "nothing has happened yet" apart from "nothing
|
|
8
|
+
* can happen": a wrong endpoint, a revoked token and an idle afternoon all
|
|
9
|
+
* look identical otherwise.
|
|
10
|
+
*/
|
|
11
|
+
export type ReceiverCheck = {
|
|
12
|
+
ok: true;
|
|
13
|
+
} | {
|
|
14
|
+
ok: false;
|
|
15
|
+
reason: "invalid_token";
|
|
16
|
+
} | {
|
|
17
|
+
ok: false;
|
|
18
|
+
reason: "unexpected_status";
|
|
19
|
+
status: number;
|
|
20
|
+
} | {
|
|
21
|
+
ok: false;
|
|
22
|
+
reason: "unreachable";
|
|
23
|
+
message: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function verifyReceiver(endpoint: string, token: string): Promise<ReceiverCheck>;
|
|
26
|
+
//# sourceMappingURL=agent-receiver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-receiver.d.ts","sourceRoot":"","sources":["../../../src/lib/agent-receiver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,MAAM,MAAM,aAAa,GACrB;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GACZ;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,GACtC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,mBAAmB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC1D;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,aAAa,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1D,wBAAsB,cAAc,CAClC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,aAAa,CAAC,CA+BxB"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Talks to the OTLP receiver directly, which is the whole point.
|
|
3
|
+
*
|
|
4
|
+
* The receiver is a different host from the web app — its own Router, its
|
|
5
|
+
* own Lambda, its own domain — so a token that works against the dashboard
|
|
6
|
+
* proves nothing about whether telemetry can reach ingest. This check is
|
|
7
|
+
* the only local way to tell "nothing has happened yet" apart from "nothing
|
|
8
|
+
* can happen": a wrong endpoint, a revoked token and an idle afternoon all
|
|
9
|
+
* look identical otherwise.
|
|
10
|
+
*/
|
|
11
|
+
const TIMEOUT_MS = 8000;
|
|
12
|
+
export async function verifyReceiver(endpoint, token) {
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch(`${endpoint.replace(/\/$/, "")}/v1/verify`, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: {
|
|
19
|
+
"content-type": "application/json",
|
|
20
|
+
authorization: `Bearer ${token}`,
|
|
21
|
+
},
|
|
22
|
+
body: "{}",
|
|
23
|
+
signal: controller.signal,
|
|
24
|
+
});
|
|
25
|
+
if (response.ok)
|
|
26
|
+
return { ok: true };
|
|
27
|
+
if (response.status === 401)
|
|
28
|
+
return { ok: false, reason: "invalid_token" };
|
|
29
|
+
// A 404 here is worth distinguishing in the caller's advice: it means the
|
|
30
|
+
// URL resolved to something that is not this receiver, or to a receiver
|
|
31
|
+
// deployed before /v1/verify existed.
|
|
32
|
+
return { ok: false, reason: "unexpected_status", status: response.status };
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
reason: "unreachable",
|
|
38
|
+
message: error instanceof Error ? error.message : "request failed",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agents `clocktopus agent` can wire up, behind one interface.
|
|
3
|
+
*
|
|
4
|
+
* Everything agent-specific lives here: where the config is, how to detect
|
|
5
|
+
* an install, what "configured" means, and how to add or remove our half of
|
|
6
|
+
* it. `setup`, `status`, `doctor` and `disable` iterate this list, so
|
|
7
|
+
* adding a third agent is a new entry rather than a new branch in five
|
|
8
|
+
* commands.
|
|
9
|
+
*
|
|
10
|
+
* The two supported agents look similar and are not:
|
|
11
|
+
*
|
|
12
|
+
* | | Claude Code | Codex CLI |
|
|
13
|
+
* | ------------- | -------------------------- | -------------------------------- |
|
|
14
|
+
* | telemetry | OTLP metrics, real dollars | OTLP **logs**, tokens only |
|
|
15
|
+
* | config | `settings.json` (one file) | `config.toml` + `hooks.json` |
|
|
16
|
+
* | credentials | env vars it injects | read back out of `config.toml` |
|
|
17
|
+
* | takes effect | restart | restart **and** a trust prompt |
|
|
18
|
+
*
|
|
19
|
+
* The credentials row is the subtle one. Claude Code applies `settings.json`
|
|
20
|
+
* `env` to the session, so our hook inherits the token from the process
|
|
21
|
+
* environment. Codex injects nothing, so the Codex hook has to read the
|
|
22
|
+
* token back out of `config.toml` — which is why `resolveAgentCredentials`
|
|
23
|
+
* takes a provider.
|
|
24
|
+
*/
|
|
25
|
+
export type AgentId = "claude" | "codex" | "opencode";
|
|
26
|
+
/** How the ingested session is labelled — must match `AgentProvider` in core. */
|
|
27
|
+
export type AgentProviderId = "claude_code" | "codex_cli" | "opencode";
|
|
28
|
+
export type AgentTelemetryState = {
|
|
29
|
+
token: string | null;
|
|
30
|
+
endpoint: string | null;
|
|
31
|
+
/** Per-event, because a half-installed hook pair degrades silently. */
|
|
32
|
+
hookCommands: Partial<Record<"SessionStart" | "SessionEnd", string>>;
|
|
33
|
+
/** Newest mtime across the agent's config files, for the restart check. */
|
|
34
|
+
modifiedAt: Date | null;
|
|
35
|
+
/** Files we would write, whether or not they exist yet. */
|
|
36
|
+
paths: string[];
|
|
37
|
+
};
|
|
38
|
+
export type AgentAdapter = {
|
|
39
|
+
id: AgentId;
|
|
40
|
+
label: string;
|
|
41
|
+
provider: AgentProviderId;
|
|
42
|
+
/** Executable name, used both to detect an install and to name it in help. */
|
|
43
|
+
binary: string;
|
|
44
|
+
/**
|
|
45
|
+
* Whether the agent runs hooks off the session's critical path itself.
|
|
46
|
+
*
|
|
47
|
+
* Claude Code does, given `"async": true`. Codex does not — 0.147 skips
|
|
48
|
+
* an async SessionStart outright and 0.148 still forces SessionEnd
|
|
49
|
+
* synchronous — so its hooks omit the key and `agent hook` backgrounds
|
|
50
|
+
* itself instead. See the header of `commands/agent/hook.ts`.
|
|
51
|
+
*/
|
|
52
|
+
hostRunsHooksAsync: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Whether the host can fire SessionEnd more than once for one session.
|
|
55
|
+
*
|
|
56
|
+
* Claude Code and Codex each end a session exactly once. OpenCode has no
|
|
57
|
+
* session-ended event at all — the plugin maps `session.idle` to
|
|
58
|
+
* SessionEnd, and that fires every time the agent stops and waits for a
|
|
59
|
+
* human, so a five-turn conversation sends five of them.
|
|
60
|
+
*
|
|
61
|
+
* The hook keeps its starting SHA in a state file keyed by session id and
|
|
62
|
+
* deletes it on SessionEnd. Deleting it after the first idle would leave
|
|
63
|
+
* every later turn with no range to diff, so nothing committed after the
|
|
64
|
+
* agent's first pause could ever be declared — see `commands/agent/hook.ts`.
|
|
65
|
+
*/
|
|
66
|
+
hostRepeatsSessionEnd: boolean;
|
|
67
|
+
/** Files this agent's telemetry configuration lives in. */
|
|
68
|
+
paths(): string[];
|
|
69
|
+
read(): AgentTelemetryState;
|
|
70
|
+
apply(input: {
|
|
71
|
+
token: string;
|
|
72
|
+
endpoint: string;
|
|
73
|
+
hookCommand: string;
|
|
74
|
+
}): {
|
|
75
|
+
backupPaths: string[];
|
|
76
|
+
};
|
|
77
|
+
remove(): {
|
|
78
|
+
removed: string[];
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Anything the user must still do by hand before telemetry flows.
|
|
82
|
+
* Empty when the agent is ready apart from a restart.
|
|
83
|
+
*/
|
|
84
|
+
pendingActions(): string[];
|
|
85
|
+
/**
|
|
86
|
+
* Why the installed configuration is behind this CLI, or null when it is
|
|
87
|
+
* current. Only defined for agents whose integration is *generated
|
|
88
|
+
* source*, which is OpenCode alone: the others store a command string
|
|
89
|
+
* that means whatever the installed CLI means, so upgrading the CLI
|
|
90
|
+
* upgrades them and there is nothing to be behind.
|
|
91
|
+
*/
|
|
92
|
+
staleReason?(): string | null;
|
|
93
|
+
};
|
|
94
|
+
/** `true` when the parse error is one of ours, whichever agent raised it. */
|
|
95
|
+
export declare function isConfigParseError(error: unknown): error is Error;
|
|
96
|
+
export declare const AGENTS: readonly AgentAdapter[];
|
|
97
|
+
export type AgentSurvey = {
|
|
98
|
+
agent: AgentAdapter;
|
|
99
|
+
/** Version string when the binary is on PATH, `null` when it is not. */
|
|
100
|
+
version: string | null;
|
|
101
|
+
installed: boolean;
|
|
102
|
+
/** True once our token is written into this agent's config. */
|
|
103
|
+
configured: boolean;
|
|
104
|
+
/** Set when the agent's config exists but could not be parsed. */
|
|
105
|
+
unreadable: string | null;
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* What is on this machine, and what is already wired up.
|
|
109
|
+
*
|
|
110
|
+
* Detection is by `--version` rather than by config directory: `~/.claude`
|
|
111
|
+
* and `~/.codex` both outlive an uninstall, so a stale directory would
|
|
112
|
+
* offer to configure an agent that is no longer there.
|
|
113
|
+
*/
|
|
114
|
+
export declare function surveyAgents(): AgentSurvey[];
|
|
115
|
+
//# sourceMappingURL=agents.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agents.d.ts","sourceRoot":"","sources":["../../../src/lib/agents.ts"],"names":[],"mappings":"AAqCA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,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;IACf;;;;;;;OAOG;IACH,kBAAkB,EAAE,OAAO,CAAC;IAC5B;;;;;;;;;;;;OAYG;IACH,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;IAChC;;;OAGG;IACH,cAAc,IAAI,MAAM,EAAE,CAAC;IAC3B;;;;;;OAMG;IACH,WAAW,CAAC,IAAI,MAAM,GAAG,IAAI,CAAC;CAC/B,CAAC;AAEF,6EAA6E;AAC7E,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,KAAK,CAKjE;AAuND,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;AAEF;;;;;;GAMG;AACH,wBAAgB,YAAY,IAAI,WAAW,EAAE,CAqB5C"}
|