@implicit-ai/relay 0.0.3 → 0.0.6

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 (2) hide show
  1. package/dist/cli.js +215 -37
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -25,24 +25,35 @@ function detectClaudeCodeInstalled() {
25
25
  }
26
26
  }
27
27
  function detectClaudeAuth() {
28
- if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
29
- return { method: "oauth" };
28
+ const available = [];
29
+ if (hasOAuthCredentials()) available.push("oauth");
30
+ if (process.env.ANTHROPIC_API_KEY) available.push("api_key");
31
+ const preferApiKey = process.env.IMPLICIT_USE_API_KEY === "1";
32
+ let method;
33
+ if (preferApiKey && available.includes("api_key")) {
34
+ method = "api_key";
35
+ } else if (available.includes("oauth")) {
36
+ method = "oauth";
37
+ } else if (available.includes("api_key")) {
38
+ method = "api_key";
39
+ } else {
40
+ method = "none";
30
41
  }
31
- if (process.env.ANTHROPIC_API_KEY) {
32
- return { method: "api_key" };
42
+ let apiKeyOverridden = false;
43
+ if (method === "oauth" && process.env.ANTHROPIC_API_KEY) {
44
+ delete process.env.ANTHROPIC_API_KEY;
45
+ apiKeyOverridden = true;
33
46
  }
47
+ return { method, available, apiKeyOverridden };
48
+ }
49
+ function hasOAuthCredentials() {
50
+ if (process.env.CLAUDE_CODE_OAUTH_TOKEN) return true;
34
51
  if (process.platform === "darwin") {
35
52
  const keychainServices = ["Claude Code-credentials", "claude.ai"];
36
53
  for (const service of keychainServices) {
37
54
  try {
38
- const result = execaSync("security", [
39
- "find-generic-password",
40
- "-s",
41
- service
42
- ]);
43
- if (result.exitCode === 0) {
44
- return { method: "oauth" };
45
- }
55
+ const result = execaSync("security", ["find-generic-password", "-s", service]);
56
+ if (result.exitCode === 0) return true;
46
57
  } catch {
47
58
  }
48
59
  }
@@ -52,13 +63,11 @@ function detectClaudeAuth() {
52
63
  if (fs.existsSync(credentialsPath)) {
53
64
  const raw = fs.readFileSync(credentialsPath, "utf-8");
54
65
  const parsed = JSON.parse(raw);
55
- if (parsed !== null && typeof parsed === "object") {
56
- return { method: "oauth" };
57
- }
66
+ if (parsed !== null && typeof parsed === "object") return true;
58
67
  }
59
68
  } catch {
60
69
  }
61
- return { method: "none" };
70
+ return false;
62
71
  }
63
72
  function printAuthGuidance() {
64
73
  console.log("Claude Code auth not found. To set up authentication:");
@@ -988,10 +997,13 @@ function truncateString(value, maxChars) {
988
997
 
989
998
  // src/lib/ably-transport.ts
990
999
  import Ably from "ably";
991
- import pino from "pino";
1000
+ import pino2 from "pino";
992
1001
 
993
1002
  // src/lib/auth-session.ts
1003
+ import { createHash } from "crypto";
994
1004
  import { createClient, isAuthRetryableFetchError } from "@supabase/supabase-js";
1005
+ import pino from "pino";
1006
+ var log = pino({ name: "auth-session" });
995
1007
  var SUPABASE_CONFIGS = {
996
1008
  production: {
997
1009
  url: "https://fqvwludchxxqfpvjzktf.supabase.co",
@@ -1003,6 +1015,10 @@ var SUPABASE_CONFIGS = {
1003
1015
  }
1004
1016
  };
1005
1017
  var REFRESH_SAFETY_MARGIN_MS = 6e4;
1018
+ function tokenFingerprint(token) {
1019
+ if (!token) return "none";
1020
+ return createHash("sha256").update(token).digest("hex").slice(0, 12);
1021
+ }
1006
1022
  var NeedsRePairingError = class extends Error {
1007
1023
  constructor() {
1008
1024
  super("Your session has expired. Run `implicit pair` to re-authenticate.");
@@ -1015,16 +1031,50 @@ var TransientAuthError = class extends Error {
1015
1031
  this.name = "TransientAuthError";
1016
1032
  }
1017
1033
  };
1034
+ var inFlightRefresh = null;
1018
1035
  async function getFreshAccessToken() {
1036
+ if (inFlightRefresh) {
1037
+ log.debug("getFreshAccessToken: joining in-flight refresh");
1038
+ return inFlightRefresh;
1039
+ }
1040
+ inFlightRefresh = doGetFreshAccessToken().finally(() => {
1041
+ inFlightRefresh = null;
1042
+ });
1043
+ return inFlightRefresh;
1044
+ }
1045
+ async function doGetFreshAccessToken() {
1019
1046
  const auth = loadAuth();
1020
1047
  if (!auth || !auth.refreshToken) {
1048
+ log.warn(
1049
+ { hasAuth: !!auth, hasRefreshToken: !!auth?.refreshToken },
1050
+ "getFreshAccessToken: no stored auth or missing refresh token \u2014 re-pair required"
1051
+ );
1021
1052
  throw new NeedsRePairingError();
1022
1053
  }
1023
- if (Date.now() < auth.expiresAt - REFRESH_SAFETY_MARGIN_MS) {
1054
+ const now = Date.now();
1055
+ const env = auth.env ?? "production";
1056
+ const rtFp = tokenFingerprint(auth.refreshToken);
1057
+ const expiresInMs = auth.expiresAt - now;
1058
+ const baseCtx = {
1059
+ env,
1060
+ userId: auth.userId,
1061
+ refreshTokenFp: rtFp,
1062
+ accessTokenExpiresAt: new Date(auth.expiresAt).toISOString(),
1063
+ accessTokenExpiresInMs: expiresInMs
1064
+ };
1065
+ if (now < auth.expiresAt - REFRESH_SAFETY_MARGIN_MS) {
1066
+ log.debug(baseCtx, "getFreshAccessToken: returning cached access token");
1024
1067
  return auth.accessToken;
1025
1068
  }
1026
- const { url, anonKey } = SUPABASE_CONFIGS[auth.env ?? "production"];
1027
- const supabase = createClient(url, anonKey);
1069
+ log.info(baseCtx, "getFreshAccessToken: token within safety margin \u2014 calling supabase.refreshSession");
1070
+ const { url, anonKey } = SUPABASE_CONFIGS[env];
1071
+ const supabase = createClient(url, anonKey, {
1072
+ auth: {
1073
+ autoRefreshToken: false,
1074
+ persistSession: false,
1075
+ detectSessionInUrl: false
1076
+ }
1077
+ });
1028
1078
  let result;
1029
1079
  try {
1030
1080
  result = await supabase.auth.refreshSession({
@@ -1032,31 +1082,72 @@ async function getFreshAccessToken() {
1032
1082
  });
1033
1083
  } catch (err) {
1034
1084
  if (isAuthRetryableFetchError(err) || isNetworkLikeError(err)) {
1085
+ log.warn(
1086
+ { ...baseCtx, errMessage: err instanceof Error ? err.message : String(err) },
1087
+ "refreshSession threw transient error \u2014 preserving auth"
1088
+ );
1035
1089
  throw new TransientAuthError(
1036
1090
  `Network error refreshing session: ${err instanceof Error ? err.message : String(err)}`
1037
1091
  );
1038
1092
  }
1093
+ log.error(
1094
+ {
1095
+ ...baseCtx,
1096
+ errName: err instanceof Error ? err.name : "?",
1097
+ errMessage: err instanceof Error ? err.message : String(err),
1098
+ errStatus: err?.status ?? "?",
1099
+ errCode: err?.code ?? "?"
1100
+ },
1101
+ "refreshSession threw non-retryable error \u2014 re-throwing (will NOT clear auth)"
1102
+ );
1039
1103
  throw err;
1040
1104
  }
1041
1105
  const { data, error } = result;
1042
1106
  if (error) {
1043
1107
  if (isAuthRetryableFetchError(error) || isNetworkLikeError(error)) {
1108
+ log.warn(
1109
+ { ...baseCtx, errMessage: error.message },
1110
+ "refreshSession returned transient error \u2014 preserving auth"
1111
+ );
1044
1112
  throw new TransientAuthError(`Network error refreshing session: ${error.message}`);
1045
1113
  }
1114
+ log.error(
1115
+ {
1116
+ ...baseCtx,
1117
+ errStatus: error.status ?? "?",
1118
+ errCode: error.code ?? "?",
1119
+ errName: error.name ?? "?",
1120
+ errMessage: error.message ?? "?"
1121
+ },
1122
+ "refreshSession rejected refresh_token \u2014 clearing auth, re-pair required"
1123
+ );
1046
1124
  clearAuth();
1047
1125
  throw new NeedsRePairingError();
1048
1126
  }
1049
1127
  if (!data.session) {
1128
+ log.error(baseCtx, "refreshSession returned no session \u2014 clearing auth, re-pair required");
1050
1129
  clearAuth();
1051
1130
  throw new NeedsRePairingError();
1052
1131
  }
1132
+ const newExpiresAt = (data.session.expires_at ?? Math.floor(Date.now() / 1e3) + 3600) * 1e3;
1053
1133
  const newAuth = {
1054
1134
  ...auth,
1055
1135
  accessToken: data.session.access_token,
1056
1136
  refreshToken: data.session.refresh_token,
1057
- expiresAt: (data.session.expires_at ?? Math.floor(Date.now() / 1e3) + 3600) * 1e3
1137
+ expiresAt: newExpiresAt
1058
1138
  };
1059
1139
  saveAuth(newAuth);
1140
+ log.info(
1141
+ {
1142
+ env,
1143
+ userId: auth.userId,
1144
+ oldRefreshTokenFp: rtFp,
1145
+ newRefreshTokenFp: tokenFingerprint(data.session.refresh_token),
1146
+ rotated: rtFp !== tokenFingerprint(data.session.refresh_token),
1147
+ newAccessTokenExpiresAt: new Date(newExpiresAt).toISOString()
1148
+ },
1149
+ "refreshSession succeeded \u2014 auth saved"
1150
+ );
1060
1151
  return data.session.access_token;
1061
1152
  }
1062
1153
  function isNetworkLikeError(err) {
@@ -1099,7 +1190,7 @@ async function fetchAblyToken(opts) {
1099
1190
  }
1100
1191
 
1101
1192
  // src/lib/ably-transport.ts
1102
- var log = pino({ name: "ably-transport" });
1193
+ var log2 = pino2({ name: "ably-transport" });
1103
1194
  var AblyTransport = class {
1104
1195
  railwayUrl;
1105
1196
  userId;
@@ -1111,6 +1202,8 @@ var AblyTransport = class {
1111
1202
  firstConnectDone = false;
1112
1203
  /** Guards against re-registering connection state listeners on reconnect. */
1113
1204
  connectionListenersWired = false;
1205
+ /** Guards against re-registering channel state listeners across attach attempts. */
1206
+ channelListenersWired = false;
1114
1207
  reconnectHandlers = [];
1115
1208
  errorHandlers = [];
1116
1209
  connectionStateHandlers = [];
@@ -1130,13 +1223,30 @@ var AblyTransport = class {
1130
1223
  this.realtime = null;
1131
1224
  this.channel = null;
1132
1225
  }
1226
+ let authCallbackInvocations = 0;
1133
1227
  this.realtime = new Ably.Realtime({
1134
1228
  authCallback: async (_tokenParams, callback) => {
1229
+ const invocation = ++authCallbackInvocations;
1230
+ log2.info({ invocation, userId: this.userId }, "Ably authCallback fired");
1135
1231
  try {
1136
1232
  const tokenDetails = await fetchAblyToken({ railwayUrl, getAccessToken: this.getAccessToken });
1233
+ log2.info(
1234
+ { invocation, ablyTokenExpires: tokenDetails.expires ? new Date(tokenDetails.expires).toISOString() : "?" },
1235
+ "Ably authCallback succeeded"
1236
+ );
1137
1237
  callback(null, tokenDetails);
1138
1238
  } catch (err) {
1139
- if (err instanceof NeedsRePairingError) {
1239
+ const isRepair = err instanceof NeedsRePairingError;
1240
+ log2.error(
1241
+ {
1242
+ invocation,
1243
+ isRepair,
1244
+ errName: err instanceof Error ? err.name : "?",
1245
+ errMessage: err instanceof Error ? err.message : String(err)
1246
+ },
1247
+ "Ably authCallback failed"
1248
+ );
1249
+ if (isRepair) {
1140
1250
  for (const h of this.errorHandlers) h(err.message);
1141
1251
  }
1142
1252
  callback(err instanceof Error ? err.message : String(err), null);
@@ -1146,42 +1256,93 @@ var AblyTransport = class {
1146
1256
  if (!this.connectionListenersWired) {
1147
1257
  this.connectionListenersWired = true;
1148
1258
  this.realtime.connection.on("connected", () => {
1149
- log.info("connection state: connected");
1259
+ log2.info({ connectionId: this.realtime?.connection.id, connectionKey: this.realtime?.connection.key }, "connection state: connected");
1150
1260
  this._isConnected = true;
1151
1261
  if (this.firstConnectDone) {
1152
- log.info({ handlers: this.reconnectHandlers.length }, "reconnected \u2014 firing handlers");
1262
+ log2.info({ handlers: this.reconnectHandlers.length }, "reconnected \u2014 firing handlers");
1153
1263
  for (const h of this.reconnectHandlers) h();
1154
1264
  }
1155
1265
  this.firstConnectDone = true;
1156
1266
  for (const h of this.connectionStateHandlers) h("connected");
1157
1267
  });
1158
1268
  this.realtime.connection.on("disconnected", (stateChange) => {
1159
- log.warn({ reason: stateChange?.reason?.message }, "connection state: disconnected");
1269
+ log2.warn({ reason: stateChange?.reason?.message }, "connection state: disconnected");
1160
1270
  this._isConnected = false;
1161
1271
  for (const h of this.connectionStateHandlers) h("disconnected");
1162
1272
  });
1163
1273
  this.realtime.connection.on("failed", (stateChange) => {
1164
1274
  const msg = stateChange?.reason?.message ?? "Ably connection failed";
1165
- log.error({ reason: msg }, "connection state: failed");
1275
+ log2.error({ reason: msg }, "connection state: failed");
1166
1276
  this._isConnected = false;
1167
1277
  for (const h of this.errorHandlers) h(msg);
1168
1278
  for (const h of this.connectionStateHandlers) h("failed");
1169
1279
  });
1170
1280
  this.realtime.connection.on("suspended", (stateChange) => {
1171
1281
  const msg = stateChange?.reason?.message ?? "Ably connection suspended";
1172
- log.warn({ reason: msg }, "connection state: suspended");
1282
+ log2.warn({ reason: msg }, "connection state: suspended");
1173
1283
  this._isConnected = false;
1174
1284
  for (const h of this.errorHandlers) h(msg);
1175
1285
  for (const h of this.connectionStateHandlers) h("suspended");
1176
1286
  });
1177
1287
  this.realtime.connection.on("closed", () => {
1178
- log.info("connection state: closed");
1288
+ log2.info("connection state: closed");
1179
1289
  this._isConnected = false;
1180
1290
  for (const h of this.connectionStateHandlers) h("closed");
1181
1291
  });
1182
1292
  }
1183
1293
  this.channel = this.realtime.channels.get(this.channelName);
1184
- await this.channel.attach();
1294
+ if (!this.channelListenersWired) {
1295
+ this.channelListenersWired = true;
1296
+ const ch = this.channel;
1297
+ ch.on("attached", (stateChange) => {
1298
+ log2.info(
1299
+ {
1300
+ channel: this.channelName,
1301
+ resumed: stateChange?.resumed,
1302
+ hasBacklog: stateChange?.hasBacklog
1303
+ },
1304
+ "channel state: attached"
1305
+ );
1306
+ });
1307
+ ch.on("failed", (stateChange) => {
1308
+ log2.error(
1309
+ { channel: this.channelName, reason: stateChange?.reason?.message, code: stateChange?.reason?.code },
1310
+ "channel state: failed"
1311
+ );
1312
+ });
1313
+ ch.on("detached", (stateChange) => {
1314
+ log2.warn(
1315
+ { channel: this.channelName, reason: stateChange?.reason?.message, code: stateChange?.reason?.code },
1316
+ "channel state: detached"
1317
+ );
1318
+ });
1319
+ ch.on("suspended", (stateChange) => {
1320
+ log2.warn(
1321
+ { channel: this.channelName, reason: stateChange?.reason?.message, code: stateChange?.reason?.code },
1322
+ "channel state: suspended"
1323
+ );
1324
+ });
1325
+ ch.on("update", (stateChange) => {
1326
+ log2.info(
1327
+ {
1328
+ channel: this.channelName,
1329
+ current: stateChange?.current,
1330
+ previous: stateChange?.previous,
1331
+ reason: stateChange?.reason?.message
1332
+ },
1333
+ "channel state: update"
1334
+ );
1335
+ });
1336
+ }
1337
+ log2.info({ channel: this.channelName }, "attaching channel");
1338
+ try {
1339
+ await this.channel.attach();
1340
+ log2.info({ channel: this.channelName, state: this.channel.state }, "attach() resolved");
1341
+ } catch (err) {
1342
+ const message = err instanceof Error ? err.message : String(err);
1343
+ log2.error({ channel: this.channelName, err: message }, "attach() rejected");
1344
+ throw err;
1345
+ }
1185
1346
  for (const { event, handler } of this.pendingSubscriptions) {
1186
1347
  this.channel.subscribe(event, (msg) => {
1187
1348
  handler(msg.data);
@@ -1240,8 +1401,15 @@ var AblyTransport = class {
1240
1401
  // --- Presence ---
1241
1402
  async presenceEnter(data) {
1242
1403
  if (!this.channel) throw new Error("AblyTransport: not connected \u2014 call connect() first");
1243
- log.info({ data }, "entering presence");
1244
- await this.channel.presence.enter(data);
1404
+ log2.info({ data, channelState: this.channel.state }, "entering presence");
1405
+ try {
1406
+ await this.channel.presence.enter(data);
1407
+ log2.info({ channel: this.channelName }, "presence.enter() resolved");
1408
+ } catch (err) {
1409
+ const message = err instanceof Error ? err.message : String(err);
1410
+ log2.error({ channel: this.channelName, err: message }, "presence.enter() rejected");
1411
+ throw err;
1412
+ }
1245
1413
  }
1246
1414
  async presenceLeave() {
1247
1415
  if (!this.channel) return;
@@ -1264,7 +1432,7 @@ var AblyTransport = class {
1264
1432
  }
1265
1433
  this.channel.presence.subscribe("enter", (msg) => {
1266
1434
  const clientId = msg.clientId ?? "unknown";
1267
- log.info({ clientId }, "presence enter");
1435
+ log2.info({ clientId }, "presence enter");
1268
1436
  handler({ clientId, data: msg.data ?? {} });
1269
1437
  });
1270
1438
  }
@@ -1275,21 +1443,21 @@ var AblyTransport = class {
1275
1443
  }
1276
1444
  this.channel.presence.subscribe("leave", (msg) => {
1277
1445
  const clientId = msg.clientId ?? "unknown";
1278
- log.info({ clientId }, "presence leave");
1446
+ log2.info({ clientId }, "presence leave");
1279
1447
  handler({ clientId, data: msg.data ?? {} });
1280
1448
  });
1281
1449
  }
1282
1450
  // --- History ---
1283
1451
  async fetchHistory(limit) {
1284
1452
  if (!this.channel) throw new Error("AblyTransport: not connected");
1285
- log.info({ limit }, "fetching channel history");
1453
+ log2.info({ limit }, "fetching channel history");
1286
1454
  const result = await this.channel.history({ limit, direction: "backwards" });
1287
1455
  const messages = (result.items ?? []).map((msg) => ({
1288
1456
  event: msg.name ?? "",
1289
1457
  data: msg.data ?? {},
1290
1458
  timestamp: msg.timestamp ?? 0
1291
1459
  }));
1292
- log.info({ count: messages.length }, "history fetched");
1460
+ log2.info({ count: messages.length }, "history fetched");
1293
1461
  return messages;
1294
1462
  }
1295
1463
  };
@@ -1324,7 +1492,17 @@ async function connect(options) {
1324
1492
  printAuthGuidance();
1325
1493
  process.exit(1);
1326
1494
  }
1327
- if (!isHeadless) console.log(` \u2713 Claude auth detected (${claudeAuth.method})`);
1495
+ if (!isHeadless) {
1496
+ console.log(` \u2713 Claude auth detected (${claudeAuth.method})`);
1497
+ if (claudeAuth.apiKeyOverridden) {
1498
+ console.log(
1499
+ " \u21B3 ANTHROPIC_API_KEY found but ignored \u2014 using Claude Pro/Max OAuth."
1500
+ );
1501
+ console.log(
1502
+ " Set IMPLICIT_USE_API_KEY=1 to bill via API instead."
1503
+ );
1504
+ }
1505
+ }
1328
1506
  let auth = options.token ? authFromAccessToken(options.token, env) : loadAuth();
1329
1507
  if (!auth) {
1330
1508
  if (isHeadless) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@implicit-ai/relay",
3
- "version": "0.0.3",
3
+ "version": "0.0.6",
4
4
  "description": "Control Claude Code from your phone with Implicit",
5
5
  "type": "module",
6
6
  "bin": {