clawmoney 0.13.15 → 0.14.0

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.
@@ -4,10 +4,29 @@ import { homedir } from "node:os";
4
4
  import YAML from "yaml";
5
5
  import { RelayWsClient } from "./ws-client.js";
6
6
  import { spawnCli, buildCliArgs, parseCliOutput, ensureEmptyMcpConfig, ensureSandboxDir, } from "./executor.js";
7
- import { callClaudeApi, preflightClaudeApi, getRateGuardSnapshot } from "./upstream/claude-api.js";
8
- import { callCodexApi, preflightCodexApi } from "./upstream/codex-api.js";
9
- import { callGeminiApi, preflightGeminiApi } from "./upstream/gemini-api.js";
10
- import { callAntigravityApi, preflightAntigravityApi, } from "./upstream/antigravity-api.js";
7
+ import { callClaudeApi, preflightClaudeApi, getRateGuardSnapshot as getClaudeRateGuardSnapshot, } from "./upstream/claude-api.js";
8
+ import { callCodexApi, preflightCodexApi, getRateGuardSnapshot as getCodexRateGuardSnapshot, } from "./upstream/codex-api.js";
9
+ import { callGeminiApi, preflightGeminiApi, getGeminiRateGuardSnapshot, } from "./upstream/gemini-api.js";
10
+ import { callAntigravityApi, preflightAntigravityApi, getAntigravityRateGuardSnapshot, } from "./upstream/antigravity-api.js";
11
+ /**
12
+ * Pick the rate-guard snapshot matching this request's cli_type. Fixes a
13
+ * pre-existing bug where gemini/codex responses were piggy-backing Claude's
14
+ * session_window telemetry because provider.ts always called the claude-api
15
+ * snapshot regardless of upstream.
16
+ */
17
+ function getRateGuardSnapshotForCli(cli) {
18
+ switch (cli) {
19
+ case "codex":
20
+ return getCodexRateGuardSnapshot();
21
+ case "gemini":
22
+ return getGeminiRateGuardSnapshot();
23
+ case "antigravity":
24
+ return getAntigravityRateGuardSnapshot();
25
+ case "claude":
26
+ default:
27
+ return getClaudeRateGuardSnapshot();
28
+ }
29
+ }
11
30
  import { calculateCost } from "./pricing.js";
12
31
  import { relayLogger as logger } from "./logger.js";
13
32
  const CONFIG_DIR = join(homedir(), ".clawmoney");
@@ -222,7 +241,7 @@ async function executeRelayRequest(request, config) {
222
241
  // actually surfaced the headers this turn.
223
242
  let sessionWindowTelemetry;
224
243
  if (useApiMode) {
225
- const snap = getRateGuardSnapshot();
244
+ const snap = getRateGuardSnapshotForCli(cliType);
226
245
  if (snap?.sessionWindow) {
227
246
  sessionWindowTelemetry = {
228
247
  reset_at_ms: snap.sessionWindow.endMs,
@@ -225,7 +225,13 @@ export function loadAccounts() {
225
225
  }
226
226
  export function saveAccounts(file) {
227
227
  ensureClawmoneyDir();
228
- writeFileSync(ACCOUNTS_FILE, JSON.stringify(file, null, 2), "utf-8");
228
+ // mode 0o600 so other local users / rogue processes can't read the
229
+ // refresh_token. Google's fraud detection treats a refresh_token seen
230
+ // from two machines / user-agents as account hijacking.
231
+ writeFileSync(ACCOUNTS_FILE, JSON.stringify(file, null, 2), {
232
+ encoding: "utf-8",
233
+ mode: 0o600,
234
+ });
229
235
  }
230
236
  function loadPrimaryAccount() {
231
237
  const file = loadAccounts();
@@ -240,17 +246,18 @@ function loadPrimaryAccount() {
240
246
  }
241
247
  return primary;
242
248
  }
249
+ /**
250
+ * Persist a partial update to the primary account record. Throws on write
251
+ * failure — refresh callers must treat that as a reason to keep the old
252
+ * token (see the "two valid tokens in flight" rationale in claude-api.ts).
253
+ * Non-refresh callers (project_id cache) can swallow the error.
254
+ */
243
255
  function persistPrimaryAccount(patch) {
244
256
  const file = loadAccounts();
245
257
  if (file.accounts.length === 0)
246
258
  return;
247
259
  file.accounts[0] = { ...file.accounts[0], ...patch };
248
- try {
249
- saveAccounts(file);
250
- }
251
- catch (err) {
252
- logger.warn(`[antigravity-api] could not persist account update: ${err.message}`);
253
- }
260
+ saveAccounts(file);
254
261
  }
255
262
  async function refreshUpstreamToken(refreshToken) {
256
263
  const params = new URLSearchParams({
@@ -291,11 +298,19 @@ async function doRefreshAndPersist(current) {
291
298
  refresh_token: fresh.refresh_token,
292
299
  expiry_ms: fresh.expiry_ms,
293
300
  };
294
- persistPrimaryAccount({
295
- access_token: next.access_token,
296
- refresh_token: next.refresh_token,
297
- expiry_ms: next.expiry_ms,
298
- });
301
+ // Persist FIRST. If writing to antigravity-accounts.json fails, keep
302
+ // using the old token — see claude-api.ts doRefreshAndPersist for why.
303
+ try {
304
+ persistPrimaryAccount({
305
+ access_token: next.access_token,
306
+ refresh_token: next.refresh_token,
307
+ expiry_ms: next.expiry_ms,
308
+ });
309
+ }
310
+ catch (err) {
311
+ logger.error(`[antigravity-api] CRITICAL: persist failed — keeping old token to avoid account-hijack detection signal: ${err.message}`);
312
+ return current;
313
+ }
299
314
  return next;
300
315
  }
301
316
  async function getFreshAccount() {
@@ -510,7 +525,14 @@ export async function preflightAntigravityApi(config) {
510
525
  logger.info("[antigravity-api] resolving project ID via loadCodeAssist...");
511
526
  const projectId = await resolveAntigravityProjectId(account.access_token);
512
527
  account.project_id = projectId;
513
- persistPrimaryAccount({ project_id: projectId });
528
+ // Persist is non-critical here: failure just means we'll re-resolve on
529
+ // next daemon restart instead of hitting the cache.
530
+ try {
531
+ persistPrimaryAccount({ project_id: projectId });
532
+ }
533
+ catch (err) {
534
+ logger.warn(`[antigravity-api] failed to cache project_id: ${err.message}`);
535
+ }
514
536
  cachedAccount = account;
515
537
  }
516
538
  logger.info(`[antigravity-api] preflight OK (project=${account.project_id}, email=${account.email ?? "?"})`);
@@ -149,26 +149,37 @@ function buildMetadataUserID(fingerprint, sessionId) {
149
149
  session_id: sessionId,
150
150
  });
151
151
  }
152
- // ── Masked session id (15-minute sliding window) ──
152
+ // ── Masked session id (3-minute sliding window, jittered) ──
153
153
  //
154
154
  // Real Claude Code reuses the same session_id across many requests in the
155
155
  // same conversation. If we randomize a new UUID per request, from Anthropic's
156
156
  // side this account produces dozens of single-request "sessions" per hour,
157
157
  // which is a strong bot signal. sub2api (identity_service.go) solves this
158
- // with a 15-minute masked session id — every request within the window gets
159
- // the same id, sliding forward on each hit. We do the same in-process.
160
- const MASKED_SESSION_TTL_MS = 15 * 60 * 1000;
158
+ // with a masked session id — every request within the window gets the same
159
+ // id, sliding forward on each hit.
160
+ //
161
+ // We use a **3-minute** window (not 15) because that matches the median real
162
+ // human coding rhythm better: a user types a prompt, reads the answer, types
163
+ // a follow-up, reads, context-switches. 15 minutes of same-session traffic
164
+ // at machine-paced intervals is itself suspicious for a human operator. We
165
+ // also add ±30s jitter so multiple providers don't all roll their sessions
166
+ // in lockstep at :00 / :03 / :06 etc — that kind of coordinated reset is an
167
+ // obvious relay-farm signature.
168
+ const MASKED_SESSION_TTL_MS = 3 * 60 * 1000; // 3 minutes
169
+ const MASKED_SESSION_JITTER_MS = 30 * 1000; // ±30s
161
170
  let maskedSessionId = null;
162
- let maskedSessionLastUsedMs = 0;
171
+ let maskedSessionExpiresAt = 0;
163
172
  function getMaskedSessionId() {
164
173
  const now = Date.now();
165
- if (maskedSessionId && now - maskedSessionLastUsedMs < MASKED_SESSION_TTL_MS) {
166
- maskedSessionLastUsedMs = now;
174
+ if (maskedSessionId && now < maskedSessionExpiresAt) {
167
175
  return maskedSessionId;
168
176
  }
169
177
  maskedSessionId = randomUUID();
170
- maskedSessionLastUsedMs = now;
171
- logger.info(`[claude-api] new masked session_id ${maskedSessionId.slice(0, 8)}... (previous expired)`);
178
+ // New window starts now, expires TTL + jitter from here.
179
+ const jitter = Math.floor((Math.random() * 2 - 1) * MASKED_SESSION_JITTER_MS);
180
+ maskedSessionExpiresAt = now + MASKED_SESSION_TTL_MS + jitter;
181
+ logger.info(`[claude-api] new masked session_id ${maskedSessionId.slice(0, 8)}... ` +
182
+ `(window=${Math.round((MASKED_SESSION_TTL_MS + jitter) / 1000)}s)`);
172
183
  return maskedSessionId;
173
184
  }
174
185
  // ── Prompt sanitization ──
@@ -275,12 +286,12 @@ function readCredentialsFromKeychain() {
275
286
  return null;
276
287
  }
277
288
  }
289
+ const CLAUDE_CREDENTIALS_FILE_PATH = join(homedir(), ".claude", ".credentials.json");
278
290
  function readCredentialsFromFile() {
279
- const path = join(homedir(), ".claude", ".credentials.json");
280
- if (!existsSync(path))
291
+ if (!existsSync(CLAUDE_CREDENTIALS_FILE_PATH))
281
292
  return null;
282
293
  try {
283
- return JSON.parse(readFileSync(path, "utf-8"));
294
+ return JSON.parse(readFileSync(CLAUDE_CREDENTIALS_FILE_PATH, "utf-8"));
284
295
  }
285
296
  catch {
286
297
  return null;
@@ -299,6 +310,7 @@ function loadClaudeOAuth() {
299
310
  }
300
311
  return {
301
312
  source: fromKeychain ? "keychain" : "file",
313
+ filePath: fromKeychain ? undefined : CLAUDE_CREDENTIALS_FILE_PATH,
302
314
  accessToken: oauth.accessToken,
303
315
  refreshToken: oauth.refreshToken,
304
316
  expiresAt: oauth.expiresAt,
@@ -368,13 +380,31 @@ async function doRefreshAndPersist(current) {
368
380
  ? fresh.scopes
369
381
  : wrapper.claudeAiOauth.scopes,
370
382
  };
383
+ // IMPORTANT: persist BEFORE advancing the in-memory state. If the keychain
384
+ // write silently fails we must NOT start using the new access/refresh token
385
+ // — doing so creates a "two valid tokens in flight" pattern that looks to
386
+ // Anthropic like account hijacking (same account_id, two access_tokens
387
+ // issued within the 3-minute refresh skew window). The correct fallback is
388
+ // to keep serving on the old token until the next refresh cycle retries
389
+ // the persist, so on-disk and in-memory state always agree.
371
390
  if (current.source === "keychain") {
372
391
  try {
373
392
  writeCredentialsToKeychain(wrapper);
374
393
  logger.info("[claude-api] keychain updated");
375
394
  }
376
395
  catch (err) {
377
- logger.warn(`[claude-api] keychain write failed: ${err.message}`);
396
+ logger.error(`[claude-api] CRITICAL: keychain write failed — keeping old token to avoid account-hijack detection signal: ${err.message}`);
397
+ return current;
398
+ }
399
+ }
400
+ else if (current.source === "file" && current.filePath) {
401
+ try {
402
+ writeFileSync(current.filePath, JSON.stringify(wrapper, null, 2), { encoding: "utf-8", mode: 0o600 });
403
+ logger.info(`[claude-api] ${current.filePath} updated`);
404
+ }
405
+ catch (err) {
406
+ logger.error(`[claude-api] CRITICAL: credential file write failed — keeping old token to avoid account-hijack detection signal: ${err.message}`);
407
+ return current;
378
408
  }
379
409
  }
380
410
  const next = {
@@ -62,7 +62,10 @@ const DEFAULT_USER_AGENT = "";
62
62
  // openai-beta header value for the 0.118+ WebSocket protocol.
63
63
  const OPENAI_BETA_WS_VALUE = "responses_websockets=2026-02-06";
64
64
  const REFRESH_SKEW_MS = 3 * 60 * 1000;
65
- const MASKED_SESSION_TTL_MS = 15 * 60 * 1000;
65
+ // Matches claude-api.ts MASKED_SESSION_TTL_MS 3 minutes with ±30s jitter
66
+ // to mimic human coding rhythm and avoid all providers rolling in lockstep.
67
+ const MASKED_SESSION_TTL_MS = 3 * 60 * 1000;
68
+ const MASKED_SESSION_JITTER_MS = 30 * 1000;
66
69
  const MAX_TRANSIENT_RETRIES = 2;
67
70
  // Per-call upper bound on how long we wait for a terminal WS frame.
68
71
  // Codex responses on small prompts come back in <10s; we give a generous
@@ -213,12 +216,17 @@ async function doRefreshAndPersist(current) {
213
216
  refresh_token: fresh.refreshToken,
214
217
  },
215
218
  };
219
+ // Persist FIRST, then advance in-memory state. If the on-disk write fails
220
+ // we keep serving on the old token — see claude-api.ts doRefreshAndPersist
221
+ // for the full rationale (OpenAI/ChatGPT would see two valid access tokens
222
+ // in flight for the same account and mark it as hijacked otherwise).
216
223
  try {
217
224
  writeCodexAuth(updatedFile);
218
225
  logger.info("[codex-api] ~/.codex/auth.json updated");
219
226
  }
220
227
  catch (err) {
221
- logger.warn(`[codex-api] failed to persist refreshed token: ${err.message}`);
228
+ logger.error(`[codex-api] CRITICAL: persist failed — keeping old token to avoid account-hijack detection signal: ${err.message}`);
229
+ return current;
222
230
  }
223
231
  return {
224
232
  accessToken: fresh.accessToken,
@@ -244,18 +252,22 @@ async function getFreshCreds() {
244
252
  cachedCreds = await refreshInflight;
245
253
  return cachedCreds;
246
254
  }
247
- // ── Masked session id (15-minute sliding window) ──
255
+ // ── Masked session id (3-minute sliding window, jittered) ──
256
+ // See the claude-api.ts copy of this block for the full rationale — real
257
+ // Codex reuses the same id across consecutive requests in a conversation;
258
+ // rolling one per request screams bot. Kept in sync with claude's TTL.
248
259
  let maskedSessionId = null;
249
- let maskedSessionLastUsedMs = 0;
260
+ let maskedSessionExpiresAt = 0;
250
261
  function getMaskedSessionId() {
251
262
  const now = Date.now();
252
- if (maskedSessionId && now - maskedSessionLastUsedMs < MASKED_SESSION_TTL_MS) {
253
- maskedSessionLastUsedMs = now;
263
+ if (maskedSessionId && now < maskedSessionExpiresAt) {
254
264
  return maskedSessionId;
255
265
  }
256
266
  maskedSessionId = randomUUID();
257
- maskedSessionLastUsedMs = now;
258
- logger.info(`[codex-api] new masked session_id ${maskedSessionId.slice(0, 8)}... (previous expired)`);
267
+ const jitter = Math.floor((Math.random() * 2 - 1) * MASKED_SESSION_JITTER_MS);
268
+ maskedSessionExpiresAt = now + MASKED_SESSION_TTL_MS + jitter;
269
+ logger.info(`[codex-api] new masked session_id ${maskedSessionId.slice(0, 8)}... ` +
270
+ `(window=${Math.round((MASKED_SESSION_TTL_MS + jitter) / 1000)}s)`);
259
271
  return maskedSessionId;
260
272
  }
261
273
  // ── Rate-limit cooldown parsing ──
@@ -125,28 +125,33 @@ function loadGeminiOAuth() {
125
125
  }
126
126
  return raw;
127
127
  }
128
+ /**
129
+ * Persist refreshed credentials to ~/.gemini/oauth_creds.json. Throws on
130
+ * failure — the caller (doRefreshAndPersist) must treat a failed write as
131
+ * a reason to keep the OLD token rather than advancing in-memory state, to
132
+ * avoid the "two valid access tokens for the same account" signal that
133
+ * Google's fraud detection interprets as account hijacking.
134
+ */
128
135
  function writeGeminiOAuth(creds) {
129
- try {
130
- const existing = existsSync(GEMINI_CREDS_FILE)
131
- ? JSON.parse(readFileSync(GEMINI_CREDS_FILE, "utf-8"))
132
- : {};
133
- const merged = {
134
- ...existing,
135
- access_token: creds.access_token,
136
- refresh_token: creds.refresh_token,
137
- expiry_date: creds.expiry_date,
138
- token_type: creds.token_type ?? existing["token_type"] ?? "Bearer",
139
- };
140
- if (creds.id_token)
141
- merged["id_token"] = creds.id_token;
142
- if (creds.scope)
143
- merged["scope"] = creds.scope;
144
- writeFileSync(GEMINI_CREDS_FILE, JSON.stringify(merged, null, 2), "utf-8");
145
- logger.info("[gemini-api] ~/.gemini/oauth_creds.json updated");
146
- }
147
- catch (err) {
148
- logger.warn(`[gemini-api] could not persist refreshed token: ${err.message}`);
149
- }
136
+ const existing = existsSync(GEMINI_CREDS_FILE)
137
+ ? JSON.parse(readFileSync(GEMINI_CREDS_FILE, "utf-8"))
138
+ : {};
139
+ const merged = {
140
+ ...existing,
141
+ access_token: creds.access_token,
142
+ refresh_token: creds.refresh_token,
143
+ expiry_date: creds.expiry_date,
144
+ token_type: creds.token_type ?? existing["token_type"] ?? "Bearer",
145
+ };
146
+ if (creds.id_token)
147
+ merged["id_token"] = creds.id_token;
148
+ if (creds.scope)
149
+ merged["scope"] = creds.scope;
150
+ writeFileSync(GEMINI_CREDS_FILE, JSON.stringify(merged, null, 2), {
151
+ encoding: "utf-8",
152
+ mode: 0o600,
153
+ });
154
+ logger.info("[gemini-api] ~/.gemini/oauth_creds.json updated");
150
155
  }
151
156
  async function refreshUpstreamToken(refreshToken) {
152
157
  // Google OAuth2 uses application/x-www-form-urlencoded (not JSON like Claude's).
@@ -193,7 +198,15 @@ async function doRefreshAndPersist(current) {
193
198
  scope: fresh.scope ?? current.scope,
194
199
  token_type: fresh.token_type,
195
200
  };
196
- writeGeminiOAuth(next);
201
+ // Persist FIRST. If writing to ~/.gemini/oauth_creds.json fails, keep the
202
+ // old token — see claude-api.ts doRefreshAndPersist for the rationale.
203
+ try {
204
+ writeGeminiOAuth(next);
205
+ }
206
+ catch (err) {
207
+ logger.error(`[gemini-api] CRITICAL: persist failed — keeping old token to avoid account-hijack detection signal: ${err.message}`);
208
+ return current;
209
+ }
197
210
  return next;
198
211
  }
199
212
  async function getFreshCreds() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmoney",
3
- "version": "0.13.15",
3
+ "version": "0.14.0",
4
4
  "description": "ClawMoney CLI -- Earn rewards with your AI agent",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env bash
2
+ # Install the ClawMoney relay daemon as a launchd user agent on macOS.
3
+ #
4
+ # Why: macOS Keychain is locked in non-interactive SSH sessions, so running
5
+ # `clawmoney relay start` via `ssh host clawmoney relay start` silently fails
6
+ # to read Claude Code's OAuth token. A launchd *user* agent (LaunchAgents)
7
+ # runs in the user's GUI session context, where the Keychain is unlocked as
8
+ # long as the user is logged in at the console.
9
+ #
10
+ # This script generates ~/Library/LaunchAgents/ai.clawmoney.relay.plist, wires
11
+ # it up to the absolute path of the clawmoney binary, and loads it. After
12
+ # running this once, the daemon will auto-start on login and auto-restart if
13
+ # it crashes — no more "why did my daemon die overnight" pages.
14
+ #
15
+ # Usage:
16
+ # ./scripts/install-daemon-launchd.sh # install + start
17
+ # ./scripts/install-daemon-launchd.sh uninstall # unload + remove plist
18
+ #
19
+ # Pre-reqs:
20
+ # - `clawmoney setup` has already written ~/.clawmoney/config.yaml
21
+ # - `clawmoney antigravity login` (if using antigravity)
22
+ # - You've installed clawmoney globally (npm i -g clawmoney)
23
+
24
+ set -euo pipefail
25
+
26
+ LABEL="ai.clawmoney.relay"
27
+ PLIST_PATH="$HOME/Library/LaunchAgents/$LABEL.plist"
28
+ LOG_DIR="$HOME/.clawmoney"
29
+ STDOUT_LOG="$LOG_DIR/launchd.out.log"
30
+ STDERR_LOG="$LOG_DIR/launchd.err.log"
31
+
32
+ if [[ "$(uname)" != "Darwin" ]]; then
33
+ echo "error: this script is for macOS only (launchd)" >&2
34
+ exit 1
35
+ fi
36
+
37
+ if [[ "${1:-}" == "uninstall" ]]; then
38
+ echo "Unloading $LABEL..."
39
+ launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
40
+ rm -f "$PLIST_PATH"
41
+ echo "Removed $PLIST_PATH."
42
+ exit 0
43
+ fi
44
+
45
+ CLAWMONEY_BIN="$(command -v clawmoney || true)"
46
+ if [[ -z "$CLAWMONEY_BIN" ]]; then
47
+ echo "error: clawmoney not found in PATH. Install with: npm i -g clawmoney" >&2
48
+ exit 1
49
+ fi
50
+
51
+ # launchd executes with a minimal PATH, so we resolve the real node too and
52
+ # pass it through EnvironmentVariables. The clawmoney binary itself is a
53
+ # Node shebang, so we need node on PATH at run time.
54
+ NODE_BIN="$(command -v node || true)"
55
+ if [[ -z "$NODE_BIN" ]]; then
56
+ echo "error: node not found in PATH" >&2
57
+ exit 1
58
+ fi
59
+ NODE_DIR="$(dirname "$NODE_BIN")"
60
+
61
+ mkdir -p "$LOG_DIR"
62
+ mkdir -p "$(dirname "$PLIST_PATH")"
63
+
64
+ cat > "$PLIST_PATH" <<EOF
65
+ <?xml version="1.0" encoding="UTF-8"?>
66
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
67
+ <plist version="1.0">
68
+ <dict>
69
+ <key>Label</key>
70
+ <string>$LABEL</string>
71
+
72
+ <key>ProgramArguments</key>
73
+ <array>
74
+ <string>$CLAWMONEY_BIN</string>
75
+ <string>relay</string>
76
+ <string>start</string>
77
+ </array>
78
+
79
+ <key>RunAtLoad</key>
80
+ <true/>
81
+
82
+ <key>KeepAlive</key>
83
+ <true/>
84
+
85
+ <!-- Restart no more than once per 10 seconds if it crashes in a loop. -->
86
+ <key>ThrottleInterval</key>
87
+ <integer>10</integer>
88
+
89
+ <!-- Run inside the user's GUI login session so the Keychain is unlocked
90
+ and we can read Claude Code's OAuth token via `security`. -->
91
+ <key>ProcessType</key>
92
+ <string>Interactive</string>
93
+
94
+ <key>EnvironmentVariables</key>
95
+ <dict>
96
+ <!-- launchd's default PATH doesn't include Homebrew / nvm. Point at the
97
+ real node dir so the clawmoney shebang can find its interpreter. -->
98
+ <key>PATH</key>
99
+ <string>$NODE_DIR:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
100
+ <key>HOME</key>
101
+ <string>$HOME</string>
102
+ </dict>
103
+
104
+ <key>WorkingDirectory</key>
105
+ <string>$HOME</string>
106
+
107
+ <key>StandardOutPath</key>
108
+ <string>$STDOUT_LOG</string>
109
+ <key>StandardErrorPath</key>
110
+ <string>$STDERR_LOG</string>
111
+ </dict>
112
+ </plist>
113
+ EOF
114
+
115
+ chmod 0644 "$PLIST_PATH"
116
+
117
+ echo "Wrote $PLIST_PATH."
118
+ echo "Loading into launchd..."
119
+
120
+ # bootout first in case we're reinstalling.
121
+ launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
122
+ launchctl bootstrap "gui/$(id -u)" "$PLIST_PATH"
123
+
124
+ echo ""
125
+ echo "Daemon is running under launchd."
126
+ echo " logs: $STDOUT_LOG / $STDERR_LOG"
127
+ echo " check: clawmoney relay status"
128
+ echo " stop: ./scripts/install-daemon-launchd.sh uninstall"