@tracemarketplace/cli 0.0.11 → 0.0.15
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/dist/api-client.d.ts +2 -2
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +2 -2
- package/dist/api-client.js.map +1 -1
- package/dist/cli.js +45 -14
- package/dist/cli.js.map +1 -1
- package/dist/commands/auto-submit.d.ts +2 -1
- package/dist/commands/auto-submit.d.ts.map +1 -1
- package/dist/commands/auto-submit.js +43 -56
- package/dist/commands/auto-submit.js.map +1 -1
- package/dist/commands/daemon.d.ts +8 -1
- package/dist/commands/daemon.d.ts.map +1 -1
- package/dist/commands/daemon.js +118 -62
- package/dist/commands/daemon.js.map +1 -1
- package/dist/commands/history.d.ts +3 -1
- package/dist/commands/history.d.ts.map +1 -1
- package/dist/commands/history.js +8 -4
- package/dist/commands/history.js.map +1 -1
- package/dist/commands/login.d.ts +5 -1
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +25 -9
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/register.d.ts +1 -0
- package/dist/commands/register.d.ts.map +1 -1
- package/dist/commands/register.js +4 -39
- package/dist/commands/register.js.map +1 -1
- package/dist/commands/remove-hook.d.ts +6 -0
- package/dist/commands/remove-hook.d.ts.map +1 -0
- package/dist/commands/remove-hook.js +174 -0
- package/dist/commands/remove-hook.js.map +1 -0
- package/dist/commands/setup-hook.d.ts +2 -0
- package/dist/commands/setup-hook.d.ts.map +1 -1
- package/dist/commands/setup-hook.js +86 -42
- package/dist/commands/setup-hook.js.map +1 -1
- package/dist/commands/status.d.ts +3 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +8 -4
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/submit.d.ts +1 -0
- package/dist/commands/submit.d.ts.map +1 -1
- package/dist/commands/submit.js +136 -83
- package/dist/commands/submit.js.map +1 -1
- package/dist/commands/whoami.d.ts +3 -1
- package/dist/commands/whoami.d.ts.map +1 -1
- package/dist/commands/whoami.js +8 -4
- package/dist/commands/whoami.js.map +1 -1
- package/dist/config.d.ts +33 -6
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +163 -17
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +8 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +16 -0
- package/dist/constants.js.map +1 -0
- package/dist/flush.d.ts +46 -0
- package/dist/flush.d.ts.map +1 -0
- package/dist/flush.js +338 -0
- package/dist/flush.js.map +1 -0
- package/dist/flush.test.d.ts +2 -0
- package/dist/flush.test.d.ts.map +1 -0
- package/dist/flush.test.js +175 -0
- package/dist/flush.test.js.map +1 -0
- package/dist/submitter.d.ts.map +1 -1
- package/dist/submitter.js +5 -2
- package/dist/submitter.js.map +1 -1
- package/package.json +8 -7
- package/src/api-client.ts +3 -3
- package/src/cli.ts +51 -14
- package/src/commands/auto-submit.ts +80 -40
- package/src/commands/daemon.ts +166 -59
- package/src/commands/history.ts +9 -4
- package/src/commands/login.ts +37 -9
- package/src/commands/register.ts +5 -49
- package/src/commands/remove-hook.ts +194 -0
- package/src/commands/setup-hook.ts +94 -44
- package/src/commands/status.ts +8 -4
- package/src/commands/submit.ts +189 -83
- package/src/commands/whoami.ts +8 -4
- package/src/config.ts +223 -21
- package/src/constants.ts +18 -0
- package/src/flush.test.ts +214 -0
- package/src/flush.ts +505 -0
- package/vitest.config.ts +8 -0
- package/src/submitter.ts +0 -110
package/src/api-client.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
export class ApiClient {
|
|
2
2
|
constructor(
|
|
3
3
|
private baseUrl: string,
|
|
4
|
-
private apiKey
|
|
4
|
+
private apiKey?: string
|
|
5
5
|
) {}
|
|
6
6
|
|
|
7
7
|
async get(path: string): Promise<unknown> {
|
|
8
8
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
9
|
-
headers: { "X-Api-Key": this.apiKey },
|
|
9
|
+
headers: this.apiKey ? { "X-Api-Key": this.apiKey } : {},
|
|
10
10
|
});
|
|
11
11
|
if (!res.ok) {
|
|
12
12
|
const text = await res.text();
|
|
@@ -20,7 +20,7 @@ export class ApiClient {
|
|
|
20
20
|
method: "POST",
|
|
21
21
|
headers: {
|
|
22
22
|
"Content-Type": "application/json",
|
|
23
|
-
"X-Api-Key": this.apiKey,
|
|
23
|
+
...(this.apiKey ? { "X-Api-Key": this.apiKey } : {}),
|
|
24
24
|
},
|
|
25
25
|
body: JSON.stringify(body),
|
|
26
26
|
});
|
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
import { dirname, join } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
2
5
|
import { program } from "commander";
|
|
3
6
|
import { loginCommand } from "./commands/login.js";
|
|
4
7
|
import { registerCommand } from "./commands/register.js";
|
|
@@ -9,37 +12,50 @@ import { historyCommand } from "./commands/history.js";
|
|
|
9
12
|
import { autoSubmitCommand } from "./commands/auto-submit.js";
|
|
10
13
|
import { setupHookCommand } from "./commands/setup-hook.js";
|
|
11
14
|
import { daemonCommand } from "./commands/daemon.js";
|
|
15
|
+
import { removeHookCommand } from "./commands/remove-hook.js";
|
|
16
|
+
import { CLI_NAME, DEFAULT_PROFILE, PROD_SERVER_URL } from "./constants.js";
|
|
17
|
+
|
|
18
|
+
const profileOptionDescription = `Config profile (default: ${DEFAULT_PROFILE})`;
|
|
19
|
+
const packageVersion = JSON.parse(
|
|
20
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../package.json"), "utf-8")
|
|
21
|
+
).version;
|
|
12
22
|
|
|
13
23
|
program
|
|
14
|
-
.name(
|
|
24
|
+
.name(CLI_NAME)
|
|
15
25
|
.description("Trace Marketplace CLI — submit AI coding sessions, get paid")
|
|
16
|
-
.version(
|
|
26
|
+
.version(packageVersion);
|
|
17
27
|
|
|
18
28
|
program
|
|
19
29
|
.command("login")
|
|
20
|
-
.description("Sign in via browser
|
|
21
|
-
.
|
|
30
|
+
.description("Sign in via browser and save your API key")
|
|
31
|
+
.option("--profile <name>", profileOptionDescription)
|
|
32
|
+
.option("--server-url <url>", `Server URL (default prod: ${PROD_SERVER_URL})`)
|
|
33
|
+
.action((opts) => loginCommand({ profile: opts.profile, serverUrl: opts.serverUrl }).catch(handleError));
|
|
22
34
|
|
|
23
35
|
program
|
|
24
36
|
.command("register")
|
|
25
|
-
.description("
|
|
26
|
-
.option("--
|
|
27
|
-
.
|
|
37
|
+
.description("Alias for login — signs in via browser and saves your API key")
|
|
38
|
+
.option("--profile <name>", profileOptionDescription)
|
|
39
|
+
.option("--server-url <url>", `Server URL (default prod: ${PROD_SERVER_URL})`)
|
|
40
|
+
.action((opts) => registerCommand({ profile: opts.profile, serverUrl: opts.serverUrl }).catch(handleError));
|
|
28
41
|
|
|
29
42
|
program
|
|
30
43
|
.command("whoami")
|
|
31
44
|
.description("Show your account info and balance")
|
|
32
|
-
.
|
|
45
|
+
.option("--profile <name>", profileOptionDescription)
|
|
46
|
+
.action((opts) => whoamiCommand({ profile: opts.profile }).catch(handleError));
|
|
33
47
|
|
|
34
48
|
program
|
|
35
49
|
.command("submit")
|
|
36
50
|
.description("Auto-detect and submit traces from Claude Code, Codex CLI, and Cursor")
|
|
51
|
+
.option("--profile <name>", profileOptionDescription)
|
|
37
52
|
.option("--tool <tool>", "Only submit from specific tool (claude-code|codex|cursor)")
|
|
38
53
|
.option("--session <id>", "Only submit a specific session ID")
|
|
39
54
|
.option("--dry-run", "Preview without submitting")
|
|
40
55
|
.option("--created-since <duration>", "Only include sessions created within duration (e.g. 30d, 12h, 30m)", "30d")
|
|
41
56
|
.action((opts) =>
|
|
42
57
|
submitCommand({
|
|
58
|
+
profile: opts.profile,
|
|
43
59
|
tool: opts.tool,
|
|
44
60
|
session: opts.session,
|
|
45
61
|
dryRun: opts.dryRun,
|
|
@@ -50,31 +66,52 @@ program
|
|
|
50
66
|
program
|
|
51
67
|
.command("status")
|
|
52
68
|
.description("Show pending submissions and balance")
|
|
53
|
-
.
|
|
69
|
+
.option("--profile <name>", profileOptionDescription)
|
|
70
|
+
.action((opts) => statusCommand({ profile: opts.profile }).catch(handleError));
|
|
54
71
|
|
|
55
72
|
program
|
|
56
73
|
.command("history")
|
|
57
74
|
.description("Show submission history")
|
|
58
|
-
.
|
|
75
|
+
.option("--profile <name>", profileOptionDescription)
|
|
76
|
+
.action((opts) => historyCommand({ profile: opts.profile }).catch(handleError));
|
|
59
77
|
|
|
60
78
|
program
|
|
61
79
|
.command("auto-submit")
|
|
62
80
|
.description("Submit the current session (called by tool hooks — do not run manually)")
|
|
81
|
+
.option("--profile <name>", profileOptionDescription)
|
|
63
82
|
.option("--tool <tool>", "Tool that triggered the hook (claude-code|cursor|codex)")
|
|
64
83
|
.option("--session <id>", "Session ID (for cursor/codex hooks)")
|
|
65
84
|
.option("--file <path>", "Direct path to session file (for claude-code)")
|
|
66
|
-
.action((opts) => autoSubmitCommand({ tool: opts.tool, session: opts.session, file: opts.file }));
|
|
85
|
+
.action((opts) => autoSubmitCommand({ profile: opts.profile, tool: opts.tool, session: opts.session, file: opts.file }));
|
|
67
86
|
|
|
68
87
|
program
|
|
69
88
|
.command("daemon")
|
|
70
|
-
.description("
|
|
71
|
-
.
|
|
89
|
+
.description("Poll for new sessions and auto-submit them; use --once for cron or --watch for live mode")
|
|
90
|
+
.option("--profile <name>", profileOptionDescription)
|
|
91
|
+
.option("--interval <seconds>", "Polling interval in seconds", "60")
|
|
92
|
+
.option("--once", "Run one polling pass and exit")
|
|
93
|
+
.option("--watch", "Use live filesystem watch mode instead of polling")
|
|
94
|
+
.action((opts) =>
|
|
95
|
+
daemonCommand({
|
|
96
|
+
profile: opts.profile,
|
|
97
|
+
interval: opts.interval,
|
|
98
|
+
once: opts.once,
|
|
99
|
+
watch: opts.watch,
|
|
100
|
+
}).catch(handleError)
|
|
101
|
+
);
|
|
72
102
|
|
|
73
103
|
program
|
|
74
104
|
.command("setup-hook")
|
|
75
105
|
.description("Install session-end hooks for Claude Code, Cursor, and Codex CLI")
|
|
106
|
+
.option("--profile <name>", profileOptionDescription)
|
|
76
107
|
.option("--tool <tool>", "Only set up hook for specific tool (claude-code|cursor|codex)")
|
|
77
|
-
.action((opts) => setupHookCommand({ tool: opts.tool }).catch(handleError));
|
|
108
|
+
.action((opts) => setupHookCommand({ profile: opts.profile, tool: opts.tool }).catch(handleError));
|
|
109
|
+
|
|
110
|
+
program
|
|
111
|
+
.command("remove-hook")
|
|
112
|
+
.description("Remove tracemp hooks from Claude Code, Cursor, and Codex CLI")
|
|
113
|
+
.option("--tool <tool>", "Only remove hook for specific tool (claude-code|cursor|codex)")
|
|
114
|
+
.action((opts) => removeHookCommand({ tool: opts.tool }).catch(handleError));
|
|
78
115
|
|
|
79
116
|
function handleError(e: unknown) {
|
|
80
117
|
console.error((e as Error).message ?? String(e));
|
|
@@ -1,36 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* auto-submit — called by tool hooks (Claude Code Stop, Cursor sessionEnd).
|
|
3
3
|
* Non-interactive: never prompts, always exits 0 so it never blocks the user's tool.
|
|
4
|
-
* Logs results to ~/.config/tracemarketplace/auto-submit.log
|
|
4
|
+
* Logs results to ~/.config/tracemarketplace/auto-submit(.<profile>).log
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync, appendFileSync, mkdirSync } from "fs";
|
|
7
|
-
import {
|
|
8
|
-
import { join } from "path";
|
|
9
|
-
import { loadConfig } from "../config.js";
|
|
7
|
+
import { getAutoSubmitLogPath, getConfigDir, loadConfig, resolveProfile } from "../config.js";
|
|
10
8
|
import { findLatestFile, findCodexFileById } from "../sessions.js";
|
|
11
|
-
import {
|
|
9
|
+
import { loginCommandForProfile, DEFAULT_PROFILE } from "../constants.js";
|
|
10
|
+
import {
|
|
11
|
+
buildCursorSessionSource,
|
|
12
|
+
buildFileSessionSource,
|
|
13
|
+
flushTrackedSessions,
|
|
14
|
+
type SessionSource,
|
|
15
|
+
} from "../flush.js";
|
|
12
16
|
|
|
13
17
|
interface AutoSubmitOptions {
|
|
18
|
+
profile?: string;
|
|
14
19
|
tool?: string;
|
|
15
20
|
session?: string; // session ID (for cursor)
|
|
16
21
|
file?: string; // direct path (for claude-code when not via hook)
|
|
17
22
|
}
|
|
18
23
|
|
|
19
|
-
export function log(msg: string) {
|
|
20
|
-
|
|
21
|
-
mkdirSync(dir, { recursive: true });
|
|
24
|
+
export function log(msg: string, profile = DEFAULT_PROFILE) {
|
|
25
|
+
mkdirSync(getConfigDir(), { recursive: true });
|
|
22
26
|
try {
|
|
23
|
-
appendFileSync(
|
|
27
|
+
appendFileSync(getAutoSubmitLogPath(profile), `[${new Date().toISOString()}] ${msg}\n`);
|
|
24
28
|
} catch {}
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
export async function autoSubmitCommand(opts: AutoSubmitOptions): Promise<void> {
|
|
28
|
-
try {
|
|
32
|
+
try {
|
|
33
|
+
await run(opts);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
log(`ERROR: ${String(err)}`, resolveProfile(opts.profile));
|
|
36
|
+
}
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
async function run(opts: AutoSubmitOptions): Promise<void> {
|
|
32
|
-
const
|
|
33
|
-
|
|
40
|
+
const profile = resolveProfile(opts.profile);
|
|
41
|
+
const config = loadConfig(profile);
|
|
42
|
+
if (!config) {
|
|
43
|
+
log(`Not authenticated — run: ${loginCommandForProfile(profile)}`, profile);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
34
46
|
|
|
35
47
|
// Read hook payload from stdin
|
|
36
48
|
let hookPayload: Record<string, unknown> = {};
|
|
@@ -40,51 +52,79 @@ async function run(opts: AutoSubmitOptions): Promise<void> {
|
|
|
40
52
|
} catch {}
|
|
41
53
|
|
|
42
54
|
const tool = opts.tool ?? inferTool(hookPayload);
|
|
43
|
-
|
|
55
|
+
const triggerSource = resolveTriggerSource(tool, opts, hookPayload);
|
|
56
|
+
if (!triggerSource && !tool) {
|
|
57
|
+
log("Could not determine tool", profile);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
log(`auto-submit triggered for profile=${profile} tool=${tool ?? "unknown"}`, profile);
|
|
62
|
+
|
|
63
|
+
const result = await flushTrackedSessions(
|
|
64
|
+
config,
|
|
65
|
+
triggerSource ? [triggerSource] : [],
|
|
66
|
+
{ includeIdleTracked: true }
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
logFlushResult(result, profile);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function logFlushResult(
|
|
73
|
+
result: Awaited<ReturnType<typeof flushTrackedSessions>>,
|
|
74
|
+
profile: string
|
|
75
|
+
) {
|
|
76
|
+
for (const session of result.results) {
|
|
77
|
+
if (session.error && session.error !== "Empty session") {
|
|
78
|
+
log(`${session.source.label}: ${session.error}`, profile);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (session.uploadedChunks > 0 || session.duplicateChunks > 0) {
|
|
83
|
+
const status = session.uploadedChunks > 0 ? "uploaded" : "duplicate";
|
|
84
|
+
log(
|
|
85
|
+
`${session.source.label}: ${status} chunks=${session.uploadedChunks + session.duplicateChunks} pending=${session.pending} payout=$${(session.payoutCents / 100).toFixed(2)}`,
|
|
86
|
+
profile
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (result.uploadedChunks === 0 && result.duplicateChunks === 0) {
|
|
92
|
+
log(`no finalized chunks ready; pending_sessions=${result.pendingSessions}`, profile);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
44
95
|
|
|
45
|
-
|
|
96
|
+
function resolveTriggerSource(
|
|
97
|
+
tool: string | null,
|
|
98
|
+
opts: AutoSubmitOptions,
|
|
99
|
+
hookPayload: Record<string, unknown>
|
|
100
|
+
): SessionSource | null {
|
|
101
|
+
if (!tool) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
46
104
|
|
|
47
105
|
if (tool === "claude-code" || tool === "claude_code") {
|
|
48
|
-
// Claude Code Stop hook sends { session_id, transcript_path }
|
|
49
106
|
const filePath = opts.file
|
|
50
107
|
?? hookPayload["transcript_path"] as string
|
|
51
108
|
?? findLatestFile("claude_code");
|
|
52
109
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const result = await submitFile("claude_code", filePath, config);
|
|
56
|
-
logResult(result, filePath);
|
|
110
|
+
return filePath ? buildFileSessionSource("claude_code", filePath) : null;
|
|
111
|
+
}
|
|
57
112
|
|
|
58
|
-
|
|
113
|
+
if (tool === "cursor") {
|
|
59
114
|
const sessionId = opts.session ?? hookPayload["sessionId"] as string;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const result = await submitCursorSession(sessionId, config);
|
|
63
|
-
logResult(result, sessionId);
|
|
115
|
+
return sessionId ? buildCursorSessionSource(sessionId) : null;
|
|
116
|
+
}
|
|
64
117
|
|
|
65
|
-
|
|
66
|
-
// after_agent payload: { "thread-id": "...", "turn-id": "...", "cwd": "...", "last-assistant-message": "..." }
|
|
67
|
-
// Legacy / manual: session_path or session_id
|
|
118
|
+
if (tool === "codex" || tool === "codex_cli") {
|
|
68
119
|
const threadId = hookPayload["thread-id"] as string ?? "";
|
|
69
120
|
const filePath = opts.file
|
|
70
121
|
?? hookPayload["session_path"] as string
|
|
71
122
|
?? findCodexFileById(opts.session ?? hookPayload["session_id"] as string ?? threadId ?? "");
|
|
72
123
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const result = await submitFile("codex_cli", filePath, config);
|
|
76
|
-
logResult(result, filePath);
|
|
77
|
-
|
|
78
|
-
} else {
|
|
79
|
-
log(`Unknown tool: ${tool}`);
|
|
124
|
+
return filePath ? buildFileSessionSource("codex_cli", filePath) : null;
|
|
80
125
|
}
|
|
81
|
-
}
|
|
82
126
|
|
|
83
|
-
|
|
84
|
-
if (result.error) { log(`${label}: ${result.error}`); return; }
|
|
85
|
-
if (result.duplicate) { log(`${label}: already captured — skipped`); return; }
|
|
86
|
-
if (result.superseded) { log(`${label}: updated (${result.turnCount} turns) — $${(result.payoutCents / 100).toFixed(2)}`); return; }
|
|
87
|
-
log(`${label}: accepted (${result.turnCount} turns) — $${(result.payoutCents / 100).toFixed(2)}`);
|
|
127
|
+
return null;
|
|
88
128
|
}
|
|
89
129
|
|
|
90
130
|
function inferTool(payload: Record<string, unknown>): string | null {
|
package/src/commands/daemon.ts
CHANGED
|
@@ -1,34 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* Keeps a state file so already-submitted unchanged files are skipped on restart.
|
|
2
|
+
* tracemp daemon — scans local session dirs and auto-submits new work.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Default mode polls on an interval, which makes it cron-friendly and simpler
|
|
5
|
+
* to reason about than a long-lived filesystem watcher. `--once` runs a single
|
|
6
|
+
* pass and exits. `--watch` preserves the old live-watch behavior.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* Server handles dedup/supersession — daemon just fires on change.
|
|
8
|
+
* State: ~/.config/tracemarketplace/daemon-state(.<profile>).json
|
|
9
|
+
* { [filePath]: { mtime: number, size: number } }
|
|
11
10
|
*/
|
|
12
11
|
import { readFileSync, writeFileSync, mkdirSync, statSync, existsSync } from "fs";
|
|
13
12
|
import { homedir } from "os";
|
|
14
13
|
import { join } from "path";
|
|
15
14
|
import chalk from "chalk";
|
|
16
|
-
import { loadConfig } from "../config.js";
|
|
15
|
+
import { type Config, getConfigDir, getDaemonStatePath, loadConfig, resolveProfile } from "../config.js";
|
|
17
16
|
import { findFiles, watchDirs } from "../sessions.js";
|
|
18
|
-
import { submitFile } from "../submitter.js";
|
|
19
17
|
import { log } from "./auto-submit.js";
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
import { loginCommandForProfile } from "../constants.js";
|
|
19
|
+
import { buildFileSessionSource, flushTrackedSessions } from "../flush.js";
|
|
22
20
|
|
|
23
21
|
type DaemonState = Record<string, { mtime: number; size: number }>;
|
|
22
|
+
const DEFAULT_INTERVAL_SECONDS = 60;
|
|
23
|
+
const BACKFILL_LOOKBACK_DAYS = 7;
|
|
24
|
+
|
|
25
|
+
interface DaemonOptions {
|
|
26
|
+
profile?: string;
|
|
27
|
+
interval?: string | number;
|
|
28
|
+
once?: boolean;
|
|
29
|
+
watch?: boolean;
|
|
30
|
+
}
|
|
24
31
|
|
|
25
|
-
function loadState(): DaemonState {
|
|
26
|
-
try { return JSON.parse(readFileSync(
|
|
32
|
+
function loadState(profile: string): DaemonState {
|
|
33
|
+
try { return JSON.parse(readFileSync(getDaemonStatePath(profile), "utf-8")); } catch { return {}; }
|
|
27
34
|
}
|
|
28
35
|
|
|
29
|
-
function saveState(state: DaemonState) {
|
|
30
|
-
mkdirSync(
|
|
31
|
-
writeFileSync(
|
|
36
|
+
function saveState(state: DaemonState, profile: string) {
|
|
37
|
+
mkdirSync(getConfigDir(), { recursive: true });
|
|
38
|
+
writeFileSync(getDaemonStatePath(profile), JSON.stringify(state, null, 2) + "\n");
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
function hasChanged(filePath: string, state: DaemonState): boolean {
|
|
@@ -50,33 +57,28 @@ function recordFile(filePath: string, state: DaemonState): DaemonState {
|
|
|
50
57
|
async function processFile(
|
|
51
58
|
tool: "claude_code" | "codex_cli",
|
|
52
59
|
filePath: string,
|
|
53
|
-
state: DaemonState
|
|
60
|
+
state: DaemonState,
|
|
61
|
+
config: Config
|
|
54
62
|
): Promise<DaemonState> {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
const result = await flushTrackedSessions(
|
|
64
|
+
config,
|
|
65
|
+
[buildFileSessionSource(tool, filePath)],
|
|
66
|
+
{ includeIdleTracked: true }
|
|
67
|
+
);
|
|
59
68
|
const updated = recordFile(filePath, state);
|
|
60
69
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
} else if (result.duplicate) {
|
|
64
|
-
// Already current — no log noise
|
|
65
|
-
} else if (result.superseded) {
|
|
66
|
-
log(`daemon: updated ${filePath} (${result.turnCount} turns) +$${(result.payoutCents / 100).toFixed(2)}`);
|
|
67
|
-
console.log(chalk.cyan(` ↑ updated`), chalk.gray(filePath.split("/").slice(-2).join("/")), chalk.green(`+$${(result.payoutCents / 100).toFixed(2)}`));
|
|
68
|
-
} else if (result.accepted) {
|
|
69
|
-
log(`daemon: accepted ${filePath} (${result.turnCount} turns) +$${(result.payoutCents / 100).toFixed(2)}`);
|
|
70
|
-
console.log(chalk.green(` ✓ new`), chalk.gray(filePath.split("/").slice(-2).join("/")), chalk.green(`+$${(result.payoutCents / 100).toFixed(2)}`));
|
|
70
|
+
for (const session of result.results) {
|
|
71
|
+
logDaemonResult(session, config.profile);
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
return updated;
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
export async function daemonCommand(): Promise<void> {
|
|
77
|
-
const
|
|
77
|
+
export async function daemonCommand(opts: DaemonOptions = {}): Promise<void> {
|
|
78
|
+
const profile = resolveProfile(opts.profile);
|
|
79
|
+
const config = loadConfig(profile);
|
|
78
80
|
if (!config) {
|
|
79
|
-
console.error(chalk.red(
|
|
81
|
+
console.error(chalk.red(`Not authenticated for profile '${profile}'. Run: ${loginCommandForProfile(profile)}`));
|
|
80
82
|
process.exit(1);
|
|
81
83
|
}
|
|
82
84
|
|
|
@@ -89,40 +91,145 @@ export async function daemonCommand(): Promise<void> {
|
|
|
89
91
|
return;
|
|
90
92
|
}
|
|
91
93
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
const intervalSeconds = parseIntervalSeconds(opts.interval);
|
|
95
|
+
let state = loadState(config.profile);
|
|
96
|
+
|
|
97
|
+
console.log(chalk.bold("tracemp daemon starting"));
|
|
98
|
+
console.log(chalk.gray(`Profile: ${config.profile}`));
|
|
99
|
+
console.log(chalk.gray(`Server: ${config.serverUrl}`));
|
|
100
|
+
console.log(chalk.gray(`Sources: ${tools.join(", ")}`));
|
|
101
|
+
|
|
102
|
+
if (opts.watch) {
|
|
103
|
+
console.log(chalk.gray("Mode: live watch"));
|
|
104
|
+
console.log(chalk.gray("Press Ctrl+C to stop\n"));
|
|
105
|
+
await runWatchLoop(config, tools, state);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log(chalk.gray(`Mode: poll every ${intervalSeconds}s${opts.once ? " (one shot)" : ""}\n`));
|
|
95
110
|
|
|
96
|
-
|
|
111
|
+
state = await runScanPass(config, tools, state, { logWhenEmpty: true });
|
|
112
|
+
if (opts.once) return;
|
|
113
|
+
|
|
114
|
+
let shuttingDown = false;
|
|
115
|
+
const stop = () => {
|
|
116
|
+
if (shuttingDown) return;
|
|
117
|
+
shuttingDown = true;
|
|
118
|
+
console.log(chalk.gray("\nDaemon stopped."));
|
|
119
|
+
process.exit(0);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
process.on("SIGINT", stop);
|
|
123
|
+
process.on("SIGTERM", stop);
|
|
124
|
+
|
|
125
|
+
while (!shuttingDown) {
|
|
126
|
+
await sleep(intervalSeconds * 1000);
|
|
127
|
+
state = await runScanPass(config, tools, state);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function runScanPass(
|
|
132
|
+
config: Config,
|
|
133
|
+
tools: Array<"claude_code" | "codex_cli">,
|
|
134
|
+
state: DaemonState,
|
|
135
|
+
opts: { logWhenEmpty?: boolean } = {}
|
|
136
|
+
): Promise<DaemonState> {
|
|
137
|
+
let nextState = state;
|
|
138
|
+
let processed = 0;
|
|
97
139
|
|
|
98
|
-
// Backfill: submit any files from the last 7 days that have changed since last run
|
|
99
|
-
console.log(chalk.gray("Backfilling new/changed sessions..."));
|
|
100
|
-
let backfilled = 0;
|
|
101
140
|
for (const tool of tools) {
|
|
102
|
-
for (const filePath of findFiles(tool,
|
|
103
|
-
if (hasChanged(filePath,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
141
|
+
for (const filePath of findFiles(tool, BACKFILL_LOOKBACK_DAYS)) {
|
|
142
|
+
if (!hasChanged(filePath, nextState)) continue;
|
|
143
|
+
nextState = await processFile(tool, filePath, nextState, config);
|
|
144
|
+
processed++;
|
|
107
145
|
}
|
|
108
146
|
}
|
|
109
|
-
saveState(state);
|
|
110
|
-
if (backfilled === 0) console.log(chalk.gray(" Nothing new.\n"));
|
|
111
|
-
else console.log();
|
|
112
147
|
|
|
113
|
-
|
|
148
|
+
const idleResults = await flushTrackedSessions(config, [], { includeIdleTracked: true });
|
|
149
|
+
for (const session of idleResults.results) {
|
|
150
|
+
if (session.uploadedChunks > 0 || session.duplicateChunks > 0) {
|
|
151
|
+
logDaemonResult(session, config.profile);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
saveState(nextState, config.profile);
|
|
156
|
+
|
|
157
|
+
if (
|
|
158
|
+
opts.logWhenEmpty &&
|
|
159
|
+
processed === 0 &&
|
|
160
|
+
idleResults.uploadedChunks === 0 &&
|
|
161
|
+
idleResults.duplicateChunks === 0
|
|
162
|
+
) {
|
|
163
|
+
console.log(chalk.gray("Nothing new.\n"));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return nextState;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function runWatchLoop(
|
|
170
|
+
config: Config,
|
|
171
|
+
tools: Array<"claude_code" | "codex_cli">,
|
|
172
|
+
state: DaemonState
|
|
173
|
+
): Promise<void> {
|
|
174
|
+
let nextState = await runScanPass(config, tools, state, { logWhenEmpty: true });
|
|
114
175
|
const stop = watchDirs(tools, async (tool, filePath) => {
|
|
115
|
-
if (!hasChanged(filePath,
|
|
116
|
-
|
|
117
|
-
saveState(
|
|
176
|
+
if (!hasChanged(filePath, nextState)) return;
|
|
177
|
+
nextState = await processFile(tool, filePath, nextState, config);
|
|
178
|
+
saveState(nextState, config.profile);
|
|
118
179
|
});
|
|
119
180
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
181
|
+
process.on("SIGINT", () => {
|
|
182
|
+
stop();
|
|
183
|
+
console.log(chalk.gray("\nDaemon stopped."));
|
|
184
|
+
process.exit(0);
|
|
185
|
+
});
|
|
186
|
+
process.on("SIGTERM", () => {
|
|
187
|
+
stop();
|
|
188
|
+
process.exit(0);
|
|
189
|
+
});
|
|
125
190
|
|
|
126
|
-
// Keep alive
|
|
127
191
|
await new Promise(() => {});
|
|
128
192
|
}
|
|
193
|
+
|
|
194
|
+
function logDaemonResult(
|
|
195
|
+
session: Awaited<ReturnType<typeof flushTrackedSessions>>["results"][number],
|
|
196
|
+
profile: string
|
|
197
|
+
) {
|
|
198
|
+
if (session.error && session.error !== "Empty session") {
|
|
199
|
+
log(`daemon: ${session.source.label}: ${session.error}`, profile);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (session.uploadedChunks > 0) {
|
|
204
|
+
log(
|
|
205
|
+
`daemon: accepted ${session.source.label} chunks=${session.uploadedChunks} +$${(session.payoutCents / 100).toFixed(2)}`,
|
|
206
|
+
profile
|
|
207
|
+
);
|
|
208
|
+
console.log(
|
|
209
|
+
chalk.green(" ✓ new"),
|
|
210
|
+
chalk.gray(session.source.label.split("/").slice(-2).join("/") || session.source.label),
|
|
211
|
+
chalk.green(`+$${(session.payoutCents / 100).toFixed(2)}`)
|
|
212
|
+
);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (session.duplicateChunks > 0) {
|
|
217
|
+
console.log(
|
|
218
|
+
chalk.cyan(" ↑ current"),
|
|
219
|
+
chalk.gray(session.source.label.split("/").slice(-2).join("/") || session.source.label)
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function parseIntervalSeconds(raw: string | number | undefined): number {
|
|
225
|
+
if (raw === undefined) return DEFAULT_INTERVAL_SECONDS;
|
|
226
|
+
const value = typeof raw === "number" ? raw : Number.parseInt(String(raw), 10);
|
|
227
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
228
|
+
throw new Error("Daemon interval must be a positive integer number of seconds.");
|
|
229
|
+
}
|
|
230
|
+
return value;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function sleep(ms: number) {
|
|
234
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
235
|
+
}
|
package/src/commands/history.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
-
import { loadConfig } from "../config.js";
|
|
2
|
+
import { loadConfig, resolveProfile } from "../config.js";
|
|
3
3
|
import { ApiClient } from "../api-client.js";
|
|
4
|
+
import { loginCommandForProfile } from "../constants.js";
|
|
4
5
|
|
|
5
|
-
export async function historyCommand(): Promise<void> {
|
|
6
|
-
const
|
|
6
|
+
export async function historyCommand(opts: { profile?: string } = {}): Promise<void> {
|
|
7
|
+
const profile = resolveProfile(opts.profile);
|
|
8
|
+
const config = loadConfig(profile);
|
|
7
9
|
if (!config) {
|
|
8
|
-
console.error(chalk.red(
|
|
10
|
+
console.error(chalk.red(`Not authenticated for profile '${profile}'. Run: ${loginCommandForProfile(profile)}`));
|
|
9
11
|
process.exit(1);
|
|
10
12
|
}
|
|
11
13
|
|
|
@@ -24,6 +26,9 @@ export async function historyCommand(): Promise<void> {
|
|
|
24
26
|
return;
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
console.log(chalk.gray(`Profile: ${config.profile}`));
|
|
30
|
+
console.log(chalk.gray(`Server: ${config.serverUrl}\n`));
|
|
31
|
+
|
|
27
32
|
console.log(
|
|
28
33
|
chalk.gray(
|
|
29
34
|
"Date".padEnd(14) +
|