@tracemarketplace/cli 0.0.13 → 0.0.17

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 (94) hide show
  1. package/dist/api-client.d.ts +9 -2
  2. package/dist/api-client.d.ts.map +1 -1
  3. package/dist/api-client.js +80 -15
  4. package/dist/api-client.js.map +1 -1
  5. package/dist/api-client.test.d.ts +2 -0
  6. package/dist/api-client.test.d.ts.map +1 -0
  7. package/dist/api-client.test.js +34 -0
  8. package/dist/api-client.test.js.map +1 -0
  9. package/dist/cli.js +48 -18
  10. package/dist/cli.js.map +1 -1
  11. package/dist/commands/auto-submit.d.ts +2 -1
  12. package/dist/commands/auto-submit.d.ts.map +1 -1
  13. package/dist/commands/auto-submit.js +43 -56
  14. package/dist/commands/auto-submit.js.map +1 -1
  15. package/dist/commands/daemon.d.ts +8 -1
  16. package/dist/commands/daemon.d.ts.map +1 -1
  17. package/dist/commands/daemon.js +184 -63
  18. package/dist/commands/daemon.js.map +1 -1
  19. package/dist/commands/history.d.ts +3 -1
  20. package/dist/commands/history.d.ts.map +1 -1
  21. package/dist/commands/history.js +8 -4
  22. package/dist/commands/history.js.map +1 -1
  23. package/dist/commands/login.d.ts +5 -1
  24. package/dist/commands/login.d.ts.map +1 -1
  25. package/dist/commands/login.js +25 -9
  26. package/dist/commands/login.js.map +1 -1
  27. package/dist/commands/register.d.ts +1 -0
  28. package/dist/commands/register.d.ts.map +1 -1
  29. package/dist/commands/register.js +4 -39
  30. package/dist/commands/register.js.map +1 -1
  31. package/dist/commands/remove-daemon.d.ts +6 -0
  32. package/dist/commands/remove-daemon.d.ts.map +1 -0
  33. package/dist/commands/remove-daemon.js +66 -0
  34. package/dist/commands/remove-daemon.js.map +1 -0
  35. package/dist/commands/remove-hook.d.ts +6 -0
  36. package/dist/commands/remove-hook.d.ts.map +1 -0
  37. package/dist/commands/remove-hook.js +174 -0
  38. package/dist/commands/remove-hook.js.map +1 -0
  39. package/dist/commands/setup-hook.d.ts +2 -0
  40. package/dist/commands/setup-hook.d.ts.map +1 -1
  41. package/dist/commands/setup-hook.js +85 -41
  42. package/dist/commands/setup-hook.js.map +1 -1
  43. package/dist/commands/status.d.ts +3 -1
  44. package/dist/commands/status.d.ts.map +1 -1
  45. package/dist/commands/status.js +8 -4
  46. package/dist/commands/status.js.map +1 -1
  47. package/dist/commands/submit.d.ts +1 -0
  48. package/dist/commands/submit.d.ts.map +1 -1
  49. package/dist/commands/submit.js +138 -83
  50. package/dist/commands/submit.js.map +1 -1
  51. package/dist/commands/whoami.d.ts +3 -1
  52. package/dist/commands/whoami.d.ts.map +1 -1
  53. package/dist/commands/whoami.js +8 -4
  54. package/dist/commands/whoami.js.map +1 -1
  55. package/dist/config.d.ts +38 -6
  56. package/dist/config.d.ts.map +1 -1
  57. package/dist/config.js +175 -17
  58. package/dist/config.js.map +1 -1
  59. package/dist/constants.d.ts +8 -0
  60. package/dist/constants.d.ts.map +1 -0
  61. package/dist/constants.js +16 -0
  62. package/dist/constants.js.map +1 -0
  63. package/dist/flush.d.ts +49 -0
  64. package/dist/flush.d.ts.map +1 -0
  65. package/dist/flush.js +405 -0
  66. package/dist/flush.js.map +1 -0
  67. package/dist/flush.test.d.ts +2 -0
  68. package/dist/flush.test.d.ts.map +1 -0
  69. package/dist/flush.test.js +330 -0
  70. package/dist/flush.test.js.map +1 -0
  71. package/dist/submitter.d.ts.map +1 -1
  72. package/dist/submitter.js +5 -2
  73. package/dist/submitter.js.map +1 -1
  74. package/package.json +8 -7
  75. package/src/api-client.test.ts +47 -0
  76. package/src/api-client.ts +100 -16
  77. package/src/cli.ts +55 -19
  78. package/src/commands/auto-submit.ts +80 -40
  79. package/src/commands/daemon.ts +243 -60
  80. package/src/commands/history.ts +9 -4
  81. package/src/commands/login.ts +37 -9
  82. package/src/commands/remove-daemon.ts +75 -0
  83. package/src/commands/remove-hook.ts +194 -0
  84. package/src/commands/setup-hook.ts +93 -43
  85. package/src/commands/status.ts +8 -4
  86. package/src/commands/submit.ts +191 -83
  87. package/src/commands/whoami.ts +8 -4
  88. package/src/config.ts +241 -21
  89. package/src/constants.ts +18 -0
  90. package/src/flush.test.ts +395 -0
  91. package/src/flush.ts +591 -0
  92. package/vitest.config.ts +8 -0
  93. package/src/commands/register.ts +0 -52
  94. package/src/submitter.ts +0 -110
package/src/cli.ts CHANGED
@@ -1,7 +1,9 @@
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
- import { registerCommand } from "./commands/register.js";
5
7
  import { whoamiCommand } from "./commands/whoami.js";
6
8
  import { submitCommand } from "./commands/submit.js";
7
9
  import { statusCommand } from "./commands/status.js";
@@ -9,37 +11,44 @@ import { historyCommand } from "./commands/history.js";
9
11
  import { autoSubmitCommand } from "./commands/auto-submit.js";
10
12
  import { setupHookCommand } from "./commands/setup-hook.js";
11
13
  import { daemonCommand } from "./commands/daemon.js";
14
+ import { removeHookCommand } from "./commands/remove-hook.js";
15
+ import { removeDaemonCommand } from "./commands/remove-daemon.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("trace")
24
+ .name(CLI_NAME)
15
25
  .description("Trace Marketplace CLI — submit AI coding sessions, get paid")
16
- .version("0.0.1");
26
+ .version(packageVersion);
17
27
 
18
28
  program
19
29
  .command("login")
20
- .description("Sign in via browser opens tracemarketplace.dev, no key needed")
21
- .action(() => loginCommand().catch(handleError));
22
-
23
- program
24
- .command("register")
25
- .description("Register your email and get an API key")
26
- .option("--server-url <url>", "Server URL (default: https://trace-marketplace-api.fly.dev)")
27
- .action((opts) => registerCommand({ serverUrl: opts.serverUrl }).catch(handleError));
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));
28
34
 
29
35
  program
30
36
  .command("whoami")
31
37
  .description("Show your account info and balance")
32
- .action(() => whoamiCommand().catch(handleError));
38
+ .option("--profile <name>", profileOptionDescription)
39
+ .action((opts) => whoamiCommand({ profile: opts.profile }).catch(handleError));
33
40
 
34
41
  program
35
42
  .command("submit")
36
43
  .description("Auto-detect and submit traces from Claude Code, Codex CLI, and Cursor")
44
+ .option("--profile <name>", profileOptionDescription)
37
45
  .option("--tool <tool>", "Only submit from specific tool (claude-code|codex|cursor)")
38
46
  .option("--session <id>", "Only submit a specific session ID")
39
47
  .option("--dry-run", "Preview without submitting")
40
48
  .option("--created-since <duration>", "Only include sessions created within duration (e.g. 30d, 12h, 30m)", "30d")
41
49
  .action((opts) =>
42
50
  submitCommand({
51
+ profile: opts.profile,
43
52
  tool: opts.tool,
44
53
  session: opts.session,
45
54
  dryRun: opts.dryRun,
@@ -50,31 +59,58 @@ program
50
59
  program
51
60
  .command("status")
52
61
  .description("Show pending submissions and balance")
53
- .action(() => statusCommand().catch(handleError));
62
+ .option("--profile <name>", profileOptionDescription)
63
+ .action((opts) => statusCommand({ profile: opts.profile }).catch(handleError));
54
64
 
55
65
  program
56
66
  .command("history")
57
67
  .description("Show submission history")
58
- .action(() => historyCommand().catch(handleError));
68
+ .option("--profile <name>", profileOptionDescription)
69
+ .action((opts) => historyCommand({ profile: opts.profile }).catch(handleError));
59
70
 
60
71
  program
61
- .command("auto-submit")
72
+ .command("auto-submit", { hidden: true })
62
73
  .description("Submit the current session (called by tool hooks — do not run manually)")
74
+ .option("--profile <name>", profileOptionDescription)
63
75
  .option("--tool <tool>", "Tool that triggered the hook (claude-code|cursor|codex)")
64
76
  .option("--session <id>", "Session ID (for cursor/codex hooks)")
65
77
  .option("--file <path>", "Direct path to session file (for claude-code)")
66
- .action((opts) => autoSubmitCommand({ tool: opts.tool, session: opts.session, file: opts.file }));
78
+ .action((opts) => autoSubmitCommand({ profile: opts.profile, tool: opts.tool, session: opts.session, file: opts.file }));
67
79
 
68
80
  program
69
81
  .command("daemon")
70
- .description("Watch session directories and auto-submit new sessions as they appear")
71
- .action(() => daemonCommand().catch(handleError));
82
+ .description("Poll for new sessions and auto-submit them; use --once for cron or --watch for live mode")
83
+ .option("--profile <name>", profileOptionDescription)
84
+ .option("--interval <seconds>", "Polling interval in seconds", "60")
85
+ .option("--once", "Run one polling pass and exit")
86
+ .option("--watch", "Use live filesystem watch mode instead of polling")
87
+ .action((opts) =>
88
+ daemonCommand({
89
+ profile: opts.profile,
90
+ interval: opts.interval,
91
+ once: opts.once,
92
+ watch: opts.watch,
93
+ }).catch(handleError)
94
+ );
72
95
 
73
96
  program
74
97
  .command("setup-hook")
75
98
  .description("Install session-end hooks for Claude Code, Cursor, and Codex CLI")
99
+ .option("--profile <name>", profileOptionDescription)
76
100
  .option("--tool <tool>", "Only set up hook for specific tool (claude-code|cursor|codex)")
77
- .action((opts) => setupHookCommand({ tool: opts.tool }).catch(handleError));
101
+ .action((opts) => setupHookCommand({ profile: opts.profile, tool: opts.tool }).catch(handleError));
102
+
103
+ program
104
+ .command("remove-hook")
105
+ .description("Remove tracemp hooks from Claude Code, Cursor, and Codex CLI")
106
+ .option("--tool <tool>", "Only remove hook for specific tool (claude-code|cursor|codex)")
107
+ .action((opts) => removeHookCommand({ tool: opts.tool }).catch(handleError));
108
+
109
+ program
110
+ .command("remove-daemon")
111
+ .description("Stop a running tracemp daemon")
112
+ .option("--profile <name>", profileOptionDescription)
113
+ .action((opts) => removeDaemonCommand({ profile: opts.profile }).catch(handleError));
78
114
 
79
115
  function handleError(e: unknown) {
80
116
  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 { homedir } from "os";
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 { submitFile, submitCursorSession } from "../submitter.js";
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
- const dir = join(homedir(), ".config", "tracemarketplace");
21
- mkdirSync(dir, { recursive: true });
24
+ export function log(msg: string, profile = DEFAULT_PROFILE) {
25
+ mkdirSync(getConfigDir(), { recursive: true });
22
26
  try {
23
- appendFileSync(join(dir, "auto-submit.log"), `[${new Date().toISOString()}] ${msg}\n`);
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 { await run(opts); } catch (err) { log(`ERROR: ${String(err)}`); }
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 config = loadConfig();
33
- if (!config) { log("Not registered — run: trace register"); return; }
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
- if (!tool) { log("Could not determine tool"); return; }
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
- log(`auto-submit triggered for tool=${tool}`);
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
- if (!filePath) { log("Claude Code: no session file found"); return; }
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
- } else if (tool === "cursor") {
113
+ if (tool === "cursor") {
59
114
  const sessionId = opts.session ?? hookPayload["sessionId"] as string;
60
- if (!sessionId) { log("Cursor: no sessionId"); return; }
61
-
62
- const result = await submitCursorSession(sessionId, config);
63
- logResult(result, sessionId);
115
+ return sessionId ? buildCursorSessionSource(sessionId) : null;
116
+ }
64
117
 
65
- } else if (tool === "codex" || tool === "codex_cli") {
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
- if (!filePath) { log("Codex: no session file found"); return; }
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
- function logResult(result: Awaited<ReturnType<typeof submitFile>>, label: string) {
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 {
@@ -1,34 +1,112 @@
1
1
  /**
2
- * trace daemon — watches ~/.claude and ~/.codex session dirs and auto-submits on change.
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
- * State: ~/.config/tracemarketplace/daemon-state.json
6
- * { [filePath]: { mtime: number, size: number } }
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
- * On startup: backfills any files newer than last run.
9
- * While running: submits files 5s after they stop changing (debounce in watchDirs).
10
- * Server handles dedup/supersession — daemon just fires on change.
8
+ * State: ~/.config/tracemarketplace/daemon-state(.<profile>).json
9
+ * { [filePath]: { mtime: number; size: number } }
10
+ *
11
+ * PID: ~/.config/tracemarketplace/daemon(.<profile>).pid
11
12
  */
12
- import { readFileSync, writeFileSync, mkdirSync, statSync, existsSync } from "fs";
13
+ import { readFileSync, writeFileSync, mkdirSync, statSync, existsSync, unlinkSync } from "fs";
13
14
  import { homedir } from "os";
14
15
  import { join } from "path";
15
16
  import chalk from "chalk";
16
- import { loadConfig } from "../config.js";
17
+ import {
18
+ type Config,
19
+ getConfigDir,
20
+ getDaemonPidPath,
21
+ getDaemonStatePath,
22
+ loadConfig,
23
+ resolveProfile,
24
+ } from "../config.js";
17
25
  import { findFiles, watchDirs } from "../sessions.js";
18
- import { submitFile } from "../submitter.js";
19
26
  import { log } from "./auto-submit.js";
20
-
21
- const STATE_PATH = join(homedir(), ".config", "tracemarketplace", "daemon-state.json");
27
+ import { CLI_NAME, DEFAULT_PROFILE, loginCommandForProfile } from "../constants.js";
28
+ import { buildFileSessionSource, flushTrackedSessions } from "../flush.js";
22
29
 
23
30
  type DaemonState = Record<string, { mtime: number; size: number }>;
31
+ const DEFAULT_INTERVAL_SECONDS = 60;
32
+ const BACKFILL_LOOKBACK_DAYS = 7;
33
+
34
+ interface DaemonOptions {
35
+ profile?: string;
36
+ interval?: string | number;
37
+ once?: boolean;
38
+ watch?: boolean;
39
+ }
40
+
41
+ function readDaemonPid(profile: string): number | null {
42
+ const pidPath = getDaemonPidPath(profile);
43
+ if (!existsSync(pidPath)) return null;
44
+
45
+ try {
46
+ const parsed = Number.parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
47
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function isProcessRunning(pid: number): boolean {
54
+ try {
55
+ process.kill(pid, 0);
56
+ return true;
57
+ } catch (err) {
58
+ return (err as NodeJS.ErrnoException).code === "EPERM";
59
+ }
60
+ }
61
+
62
+ function removeDaemonPidFile(profile: string, expectedPid?: number) {
63
+ const pidPath = getDaemonPidPath(profile);
64
+ if (!existsSync(pidPath)) return;
65
+
66
+ try {
67
+ if (expectedPid !== undefined) {
68
+ const currentPid = Number.parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
69
+ if (currentPid !== expectedPid) return;
70
+ }
71
+ unlinkSync(pidPath);
72
+ } catch {}
73
+ }
74
+
75
+ function reserveDaemonPid(profile: string): () => void {
76
+ const existingPid = readDaemonPid(profile);
77
+ if (existingPid !== null) {
78
+ if (isProcessRunning(existingPid)) {
79
+ const profileArg = profile === DEFAULT_PROFILE ? "" : ` --profile ${profile}`;
80
+ throw new Error(
81
+ `Daemon already running for profile '${profile}' (pid ${existingPid}). Run: ${CLI_NAME} remove-daemon${profileArg}`
82
+ );
83
+ }
84
+ removeDaemonPidFile(profile);
85
+ } else {
86
+ removeDaemonPidFile(profile);
87
+ }
88
+
89
+ mkdirSync(getConfigDir(), { recursive: true });
90
+ writeFileSync(getDaemonPidPath(profile), `${process.pid}\n`, "utf-8");
91
+
92
+ let released = false;
93
+ const release = () => {
94
+ if (released) return;
95
+ released = true;
96
+ removeDaemonPidFile(profile, process.pid);
97
+ };
98
+
99
+ process.once("exit", release);
100
+ return release;
101
+ }
24
102
 
25
- function loadState(): DaemonState {
26
- try { return JSON.parse(readFileSync(STATE_PATH, "utf-8")); } catch { return {}; }
103
+ function loadState(profile: string): DaemonState {
104
+ try { return JSON.parse(readFileSync(getDaemonStatePath(profile), "utf-8")); } catch { return {}; }
27
105
  }
28
106
 
29
- function saveState(state: DaemonState) {
30
- mkdirSync(join(homedir(), ".config", "tracemarketplace"), { recursive: true });
31
- writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
107
+ function saveState(state: DaemonState, profile: string) {
108
+ mkdirSync(getConfigDir(), { recursive: true });
109
+ writeFileSync(getDaemonStatePath(profile), JSON.stringify(state, null, 2) + "\n");
32
110
  }
33
111
 
34
112
  function hasChanged(filePath: string, state: DaemonState): boolean {
@@ -50,33 +128,28 @@ function recordFile(filePath: string, state: DaemonState): DaemonState {
50
128
  async function processFile(
51
129
  tool: "claude_code" | "codex_cli",
52
130
  filePath: string,
53
- state: DaemonState
131
+ state: DaemonState,
132
+ config: Config
54
133
  ): Promise<DaemonState> {
55
- const config = loadConfig();
56
- if (!config) return state;
57
-
58
- const result = await submitFile(tool, filePath, config);
134
+ const result = await flushTrackedSessions(
135
+ config,
136
+ [buildFileSessionSource(tool, filePath)],
137
+ { includeIdleTracked: true }
138
+ );
59
139
  const updated = recordFile(filePath, state);
60
140
 
61
- if (result.error && result.error !== "Empty session") {
62
- log(`daemon: ${filePath}: ${result.error}`);
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)}`));
141
+ for (const session of result.results) {
142
+ logDaemonResult(session, config.profile);
71
143
  }
72
144
 
73
145
  return updated;
74
146
  }
75
147
 
76
- export async function daemonCommand(): Promise<void> {
77
- const config = loadConfig();
148
+ export async function daemonCommand(opts: DaemonOptions = {}): Promise<void> {
149
+ const profile = resolveProfile(opts.profile);
150
+ const config = loadConfig(profile);
78
151
  if (!config) {
79
- console.error(chalk.red("Not registered. Run: trace register"));
152
+ console.error(chalk.red(`Not authenticated for profile '${profile}'. Run: ${loginCommandForProfile(profile)}`));
80
153
  process.exit(1);
81
154
  }
82
155
 
@@ -89,40 +162,150 @@ export async function daemonCommand(): Promise<void> {
89
162
  return;
90
163
  }
91
164
 
92
- console.log(chalk.bold("Trace daemon starting"));
93
- console.log(chalk.gray(`Watching: ${tools.join(", ")}`));
94
- console.log(chalk.gray("Press Ctrl+C to stop\n"));
165
+ const intervalSeconds = parseIntervalSeconds(opts.interval);
166
+ let state = loadState(config.profile);
167
+ const releasePid = opts.once ? null : reserveDaemonPid(config.profile);
168
+
169
+ console.log(chalk.bold("tracemp daemon starting"));
170
+ console.log(chalk.gray(`Profile: ${config.profile}`));
171
+ console.log(chalk.gray(`Server: ${config.serverUrl}`));
172
+ console.log(chalk.gray(`Sources: ${tools.join(", ")}`));
173
+
174
+ if (opts.watch) {
175
+ console.log(chalk.gray("Mode: live watch"));
176
+ console.log(chalk.gray("Press Ctrl+C to stop\n"));
177
+ await runWatchLoop(config, tools, state, releasePid ?? (() => {}));
178
+ return;
179
+ }
180
+
181
+ console.log(chalk.gray(`Mode: poll every ${intervalSeconds}s${opts.once ? " (one shot)" : ""}\n`));
182
+
183
+ state = await runScanPass(config, tools, state, { logWhenEmpty: true });
184
+ if (opts.once) return;
95
185
 
96
- let state = loadState();
186
+ let shuttingDown = false;
187
+ const stop = () => {
188
+ if (shuttingDown) return;
189
+ shuttingDown = true;
190
+ releasePid?.();
191
+ console.log(chalk.gray("\nDaemon stopped."));
192
+ process.exit(0);
193
+ };
194
+
195
+ process.on("SIGINT", stop);
196
+ process.on("SIGTERM", stop);
197
+
198
+ while (!shuttingDown) {
199
+ await sleep(intervalSeconds * 1000);
200
+ state = await runScanPass(config, tools, state);
201
+ }
202
+ }
203
+
204
+ async function runScanPass(
205
+ config: Config,
206
+ tools: Array<"claude_code" | "codex_cli">,
207
+ state: DaemonState,
208
+ opts: { logWhenEmpty?: boolean } = {}
209
+ ): Promise<DaemonState> {
210
+ let nextState = state;
211
+ let processed = 0;
97
212
 
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
213
  for (const tool of tools) {
102
- for (const filePath of findFiles(tool, 7)) {
103
- if (hasChanged(filePath, state)) {
104
- state = await processFile(tool, filePath, state);
105
- backfilled++;
106
- }
214
+ for (const filePath of findFiles(tool, BACKFILL_LOOKBACK_DAYS)) {
215
+ if (!hasChanged(filePath, nextState)) continue;
216
+ nextState = await processFile(tool, filePath, nextState, config);
217
+ processed++;
218
+ }
219
+ }
220
+
221
+ const idleResults = await flushTrackedSessions(config, [], { includeIdleTracked: true });
222
+ for (const session of idleResults.results) {
223
+ if (session.uploadedChunks > 0 || session.duplicateChunks > 0) {
224
+ logDaemonResult(session, config.profile);
107
225
  }
108
226
  }
109
- saveState(state);
110
- if (backfilled === 0) console.log(chalk.gray(" Nothing new.\n"));
111
- else console.log();
112
227
 
113
- // Watch for ongoing changes
228
+ saveState(nextState, config.profile);
229
+
230
+ if (
231
+ opts.logWhenEmpty &&
232
+ processed === 0 &&
233
+ idleResults.uploadedChunks === 0 &&
234
+ idleResults.duplicateChunks === 0
235
+ ) {
236
+ console.log(chalk.gray("Nothing new.\n"));
237
+ }
238
+
239
+ return nextState;
240
+ }
241
+
242
+ async function runWatchLoop(
243
+ config: Config,
244
+ tools: Array<"claude_code" | "codex_cli">,
245
+ state: DaemonState,
246
+ releasePid: () => void
247
+ ): Promise<void> {
248
+ let nextState = await runScanPass(config, tools, state, { logWhenEmpty: true });
114
249
  const stop = watchDirs(tools, async (tool, filePath) => {
115
- if (!hasChanged(filePath, state)) return;
116
- state = await processFile(tool, filePath, state);
117
- saveState(state);
250
+ if (!hasChanged(filePath, nextState)) return;
251
+ nextState = await processFile(tool, filePath, nextState, config);
252
+ saveState(nextState, config.profile);
118
253
  });
119
254
 
120
- console.log(chalk.gray("Watching for new sessions...\n"));
121
-
122
- // Graceful shutdown
123
- process.on("SIGINT", () => { stop(); console.log(chalk.gray("\nDaemon stopped.")); process.exit(0); });
124
- process.on("SIGTERM", () => { stop(); process.exit(0); });
255
+ process.on("SIGINT", () => {
256
+ stop();
257
+ releasePid();
258
+ console.log(chalk.gray("\nDaemon stopped."));
259
+ process.exit(0);
260
+ });
261
+ process.on("SIGTERM", () => {
262
+ stop();
263
+ releasePid();
264
+ process.exit(0);
265
+ });
125
266
 
126
- // Keep alive
127
267
  await new Promise(() => {});
128
268
  }
269
+
270
+ function logDaemonResult(
271
+ session: Awaited<ReturnType<typeof flushTrackedSessions>>["results"][number],
272
+ profile: string
273
+ ) {
274
+ if (session.error && session.error !== "Empty session") {
275
+ log(`daemon: ${session.source.label}: ${session.error}`, profile);
276
+ return;
277
+ }
278
+
279
+ if (session.uploadedChunks > 0) {
280
+ log(
281
+ `daemon: accepted ${session.source.label} chunks=${session.uploadedChunks} +$${(session.payoutCents / 100).toFixed(2)}`,
282
+ profile
283
+ );
284
+ console.log(
285
+ chalk.green(" ✓ new"),
286
+ chalk.gray(session.source.label.split("/").slice(-2).join("/") || session.source.label),
287
+ chalk.green(`+$${(session.payoutCents / 100).toFixed(2)}`)
288
+ );
289
+ return;
290
+ }
291
+
292
+ if (session.duplicateChunks > 0) {
293
+ console.log(
294
+ chalk.cyan(" ↑ current"),
295
+ chalk.gray(session.source.label.split("/").slice(-2).join("/") || session.source.label)
296
+ );
297
+ }
298
+ }
299
+
300
+ function parseIntervalSeconds(raw: string | number | undefined): number {
301
+ if (raw === undefined) return DEFAULT_INTERVAL_SECONDS;
302
+ const value = typeof raw === "number" ? raw : Number.parseInt(String(raw), 10);
303
+ if (!Number.isFinite(value) || value <= 0) {
304
+ throw new Error("Daemon interval must be a positive integer number of seconds.");
305
+ }
306
+ return value;
307
+ }
308
+
309
+ function sleep(ms: number) {
310
+ return new Promise((resolve) => setTimeout(resolve, ms));
311
+ }