@aixle/insights 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,10 @@ const CATEGORIES = {
12
12
  weight: 3,
13
13
  patterns: [
14
14
  /\b\d{3}-\d{2}-\d{4}\b/g, // SSN
15
+ /* eslint-disable-next-line security/detect-unsafe-regex -- Flagged for
16
+ `{3}` inside `(?:…)?`. Every branch is a fixed-length digit run
17
+ anchored by \b, so the match is bounded and cannot backtrack
18
+ super-linearly. */
15
19
  /\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|6(?:011|5[0-9]{2})[0-9]{12}|(?:2131|1800|35\d{3})\d{11})\b/g, // Credit card
16
20
  ],
17
21
  },
@@ -19,6 +23,9 @@ const CATEGORIES = {
19
23
  weight: 1,
20
24
  patterns: [
21
25
  /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gi, // Email
26
+ /* eslint-disable-next-line security/detect-unsafe-regex -- Flagged for
27
+ `?` nested in `?`. All quantified groups are fixed-length digit or
28
+ separator classes anchored by \b; matching is bounded. */
22
29
  /\b(?:\+?1[-.\s]?)?(?:\([0-9]{3}\)|[0-9]{3})[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\b/g, // Phone
23
30
  ],
24
31
  },
package/dist/server.d.ts CHANGED
@@ -7,8 +7,8 @@ export declare const SYNC_NOW_INPUT_SCHEMA: z.ZodObject<{
7
7
  cursor: "cursor";
8
8
  }>>>;
9
9
  }, z.core.$strict>;
10
- /** Structured status for `db90_status` — tolerates missing/malformed credentials and state. */
11
- export declare function buildDb90StatusPayload(): Promise<Record<string, unknown>>;
10
+ /** Structured status for `aixle_insights_status` — tolerates missing/malformed credentials and state. */
11
+ export declare function buildAixleInsightsStatusPayload(): Promise<Record<string, unknown>>;
12
12
  /** In-process MCP server instance (stdio not attached). */
13
- export declare function createDb90McpServer(): McpServer;
13
+ export declare function createAixleInsightsMcpServer(): McpServer;
14
14
  export declare function startServer(): Promise<void>;
package/dist/server.js CHANGED
@@ -27,6 +27,12 @@ export const SYNC_NOW_INPUT_SCHEMA = z
27
27
  }
28
28
  })
29
29
  .strict();
30
+ const AUTHENTICATE_INPUT_SCHEMA = z.object({
31
+ keycloakUrl: z.string().optional(),
32
+ clientId: z.string().optional(),
33
+ });
34
+ /** DB90DV-569: `db90_*` names are deprecated aliases kept for one release for existing callers. */
35
+ const DEPRECATED_ALIAS_NOTE = "(Deprecated — use `{name}` instead; kept temporarily for backward compatibility, see DB90DV-569.) ";
30
36
  function jsonContent(value) {
31
37
  return {
32
38
  content: [
@@ -70,22 +76,22 @@ async function getProjectResolutionForSync(creds) {
70
76
  if (cachedProjectResolution?.key === cacheKey) {
71
77
  return cachedProjectResolution.value;
72
78
  }
73
- const result = await resolveProjectId(undefined, undefined, creds.host, token, false);
79
+ const result = await resolveProjectId(undefined, undefined, creds.host, token, false, creds.insecureHttpAllowed === true);
74
80
  mcpLog.info("project_attribution_resolved", { project_id: result.projectId, source: result.source }, false);
75
81
  if (result.source !== "none") {
76
82
  cachedProjectResolution = { key: cacheKey, value: result };
77
83
  }
78
84
  return result;
79
85
  }
80
- /** Structured status for `db90_status` — tolerates missing/malformed credentials and state. */
81
- export async function buildDb90StatusPayload() {
86
+ /** Structured status for `aixle_insights_status` — tolerates missing/malformed credentials and state. */
87
+ export async function buildAixleInsightsStatusPayload() {
82
88
  const snapshot = await buildHealthSnapshot();
83
89
  return healthSnapshotToStatusPayload(snapshot);
84
90
  }
85
91
  async function executeSync(parsed) {
86
92
  const creds = await loadCredentials();
87
93
  if (!creds || !credentialsHaveAnyToken(creds)) {
88
- mcpLog.warn("credential_validation_failed", { source: "db90_sync_now", reason: "missing_credentials" }, false);
94
+ mcpLog.warn("credential_validation_failed", { source: "aixle_insights_sync_now", reason: "missing_credentials" }, false);
89
95
  return { ok: false, error: "missing_credentials" };
90
96
  }
91
97
  migrateAllLegacyState(creds);
@@ -104,79 +110,84 @@ async function executeSync(parsed) {
104
110
  });
105
111
  return { ok: syncResultOk(result), result };
106
112
  }
107
- /** In-process MCP server instance (stdio not attached). */
108
- export function createDb90McpServer() {
109
- const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
110
- server.registerTool("db90_status", {
111
- description: "Returns Aixle Insights MCP connectivity and last sync metadata from disk (credentials + state). No arguments.",
112
- }, async () => jsonContent(await buildDb90StatusPayload()));
113
- server.registerTool("db90_sync_now", {
114
- description: "Runs one DB90 ingest sync cycle for enabled tools immediately (matches background cadence). " +
115
- "Optional `tools` subset filter: omit to sync every tool credential you have authenticated (Claude transcripts + Cursor telemetry).",
116
- inputSchema: SYNC_NOW_INPUT_SCHEMA,
117
- }, async (input) => {
118
- try {
119
- const parsed = SYNC_NOW_INPUT_SCHEMA.parse(input ?? {});
120
- return jsonContent(await executeSync(parsed));
121
- }
122
- catch (err) {
123
- if (err instanceof z.ZodError) {
124
- return jsonContent({
125
- ok: false,
126
- error: "validation_error",
127
- details: err.flatten(),
128
- });
129
- }
113
+ async function statusHandler() {
114
+ return jsonContent(await buildAixleInsightsStatusPayload());
115
+ }
116
+ async function syncNowHandler(input) {
117
+ try {
118
+ const parsed = SYNC_NOW_INPUT_SCHEMA.parse(input ?? {});
119
+ return jsonContent(await executeSync(parsed));
120
+ }
121
+ catch (err) {
122
+ if (err instanceof z.ZodError) {
130
123
  return jsonContent({
131
124
  ok: false,
132
- error: err instanceof Error ? err.message : String(err),
133
- });
134
- }
135
- });
136
- server.registerTool("db90_authenticate", {
137
- description: "Starts Keycloak device login and returns the visit URL/code for the user. Use aixle-insights init for the full terminal flow that saves credentials.",
138
- inputSchema: z.object({
139
- keycloakUrl: z.string().optional(),
140
- clientId: z.string().optional(),
141
- }),
142
- }, async (input) => {
143
- try {
144
- const args = input;
145
- const kc = (args.keycloakUrl?.trim() || defaultKeycloakIssuer()).trim();
146
- if (!kc) {
147
- return jsonContent({
148
- ok: false,
149
- error: "keycloakUrl or KEYCLOAK_ISSUER / DB90_KEYCLOAK_ISSUER is required",
150
- });
151
- }
152
- const clientId = args.clientId?.trim() || defaultKeycloakClientId();
153
- const device = await startDeviceAuthorization({
154
- issuer: kc,
155
- clientId,
156
- });
157
- return jsonContent({
158
- ok: true,
159
- verificationUri: device.verification_uri,
160
- verificationUriComplete: device.verification_uri_complete ?? null,
161
- userCode: device.user_code,
162
- expiresIn: device.expires_in,
163
- interval: device.interval ?? 5,
164
- issuer: kc,
165
- clientId,
166
- message: `Visit ${device.verification_uri} and enter code ${device.user_code}`,
125
+ error: "validation_error",
126
+ details: err.flatten(),
167
127
  });
168
128
  }
169
- catch (err) {
129
+ return jsonContent({
130
+ ok: false,
131
+ error: err instanceof Error ? err.message : String(err),
132
+ });
133
+ }
134
+ }
135
+ async function authenticateHandler(args) {
136
+ try {
137
+ const kc = (args.keycloakUrl?.trim() || defaultKeycloakIssuer()).trim();
138
+ if (!kc) {
170
139
  return jsonContent({
171
140
  ok: false,
172
- error: err instanceof Error ? err.message : String(err),
141
+ error: "keycloakUrl or KEYCLOAK_ISSUER / DB90_KEYCLOAK_ISSUER is required",
173
142
  });
174
143
  }
175
- });
144
+ const clientId = args.clientId?.trim() || defaultKeycloakClientId();
145
+ const device = await startDeviceAuthorization({
146
+ issuer: kc,
147
+ clientId,
148
+ });
149
+ return jsonContent({
150
+ ok: true,
151
+ verificationUri: device.verification_uri,
152
+ verificationUriComplete: device.verification_uri_complete ?? null,
153
+ userCode: device.user_code,
154
+ expiresIn: device.expires_in,
155
+ interval: device.interval ?? 5,
156
+ issuer: kc,
157
+ clientId,
158
+ message: `Visit ${device.verification_uri} and enter code ${device.user_code}`,
159
+ });
160
+ }
161
+ catch (err) {
162
+ return jsonContent({
163
+ ok: false,
164
+ error: err instanceof Error ? err.message : String(err),
165
+ });
166
+ }
167
+ }
168
+ /** In-process MCP server instance (stdio not attached). */
169
+ export function createAixleInsightsMcpServer() {
170
+ const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
171
+ const statusDescription = "Returns Aixle Insights MCP connectivity and last sync metadata from disk (credentials + state). No arguments.";
172
+ server.registerTool("aixle_insights_status", { description: statusDescription }, statusHandler);
173
+ server.registerTool("db90_status", { description: DEPRECATED_ALIAS_NOTE.replace("{name}", "aixle_insights_status") + statusDescription }, statusHandler);
174
+ const syncNowDescription = "Runs one DB90 ingest sync cycle for enabled tools immediately (matches background cadence). " +
175
+ "Optional `tools` subset filter: omit to sync every tool credential you have authenticated (Claude transcripts + Cursor telemetry).";
176
+ server.registerTool("aixle_insights_sync_now", { description: syncNowDescription, inputSchema: SYNC_NOW_INPUT_SCHEMA }, syncNowHandler);
177
+ server.registerTool("db90_sync_now", {
178
+ description: DEPRECATED_ALIAS_NOTE.replace("{name}", "aixle_insights_sync_now") + syncNowDescription,
179
+ inputSchema: SYNC_NOW_INPUT_SCHEMA,
180
+ }, syncNowHandler);
181
+ const authenticateDescription = "Starts Keycloak device login and returns the visit URL/code for the user. Use aixle-insights init for the full terminal flow that saves credentials.";
182
+ server.registerTool("aixle_insights_authenticate", { description: authenticateDescription, inputSchema: AUTHENTICATE_INPUT_SCHEMA }, authenticateHandler);
183
+ server.registerTool("db90_authenticate", {
184
+ description: DEPRECATED_ALIAS_NOTE.replace("{name}", "aixle_insights_authenticate") + authenticateDescription,
185
+ inputSchema: AUTHENTICATE_INPUT_SCHEMA,
186
+ }, authenticateHandler);
176
187
  return server;
177
188
  }
178
189
  export async function startServer() {
179
- const server = createDb90McpServer();
190
+ const server = createAixleInsightsMcpServer();
180
191
  const transport = new StdioServerTransport();
181
192
  await server.connect(transport);
182
193
  let intervalId;
package/dist/state.js CHANGED
@@ -2,6 +2,8 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { createHash, randomBytes } from "node:crypto";
5
+ import { mcpLog } from "./log.js";
6
+ import { describeReadFailure } from "./lib/parse-error.js";
5
7
  export function getAppDir() {
6
8
  const override = process.env["AIXLE_INSIGHTS_HOME"]?.trim();
7
9
  if (override && override.length > 0)
@@ -89,44 +91,50 @@ export function migrateLegacyState(dir, host, token) {
89
91
  }
90
92
  export function readState(dir, host, token) {
91
93
  const filePath = stateFilePath(dir ?? getAppDir(), host, token);
94
+ let parsed;
92
95
  try {
93
- const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
94
- if (typeof parsed === "object" && parsed !== null) {
95
- const p = parsed;
96
- if (typeof p.version === "number" &&
97
- typeof p.sessions === "object" &&
98
- p.sessions !== null) {
99
- const lastRecentCommitHashes = Array.isArray(p.lastRecentCommitHashes)
100
- ? p.lastRecentCommitHashes.filter((h) => typeof h === "string")
101
- : undefined;
102
- const out = {
103
- version: p.version,
104
- sessions: p.sessions,
105
- };
106
- if (lastRecentCommitHashes !== undefined) {
107
- out.lastRecentCommitHashes = lastRecentCommitHashes;
108
- }
109
- if ("mcp_operator" in p) {
110
- const mcp = parseMcpOperator(p.mcp_operator);
111
- if (mcp)
112
- out.mcp_operator = mcp;
113
- }
114
- if ("rate_limited_until" in p) {
115
- if (typeof p.rate_limited_until === "string") {
116
- out.rate_limited_until = p.rate_limited_until;
117
- }
118
- else if (p.rate_limited_until === null) {
119
- out.rate_limited_until = null;
120
- }
121
- }
122
- return out;
123
- }
96
+ parsed = JSON.parse(readFileSync(filePath, "utf-8"));
97
+ }
98
+ catch (err) {
99
+ const code = err?.code;
100
+ if (code !== "ENOENT") {
101
+ // State file exists but is not valid JSON — distinguishes tampering from "never created".
102
+ // ENOENT stays silent: that is the normal first-run case.
103
+ mcpLog.warn("state_parse_failed", { path: filePath, ...describeReadFailure(err) }, false);
124
104
  }
105
+ return { version: 1, sessions: {} };
125
106
  }
126
- catch {
127
- // missing or malformed state file start fresh
107
+ const p = typeof parsed === "object" && parsed !== null ? parsed : null;
108
+ if (p === null || typeof p.version !== "number" || typeof p.sessions !== "object" || p.sessions === null) {
109
+ // Valid JSON, wrong shape. This fallback discards every dedup checkpoint and causes a full
110
+ // re-send, so it is the most consequential of the four to have been silent. (DB90DV-699)
111
+ mcpLog.warn("state_parse_failed", { path: filePath, reason: "invalid_shape" }, false);
112
+ return { version: 1, sessions: {} };
113
+ }
114
+ const lastRecentCommitHashes = Array.isArray(p.lastRecentCommitHashes)
115
+ ? p.lastRecentCommitHashes.filter((h) => typeof h === "string")
116
+ : undefined;
117
+ const out = {
118
+ version: p.version,
119
+ sessions: p.sessions,
120
+ };
121
+ if (lastRecentCommitHashes !== undefined) {
122
+ out.lastRecentCommitHashes = lastRecentCommitHashes;
123
+ }
124
+ if ("mcp_operator" in p) {
125
+ const mcp = parseMcpOperator(p.mcp_operator);
126
+ if (mcp)
127
+ out.mcp_operator = mcp;
128
+ }
129
+ if ("rate_limited_until" in p) {
130
+ if (typeof p.rate_limited_until === "string") {
131
+ out.rate_limited_until = p.rate_limited_until;
132
+ }
133
+ else if (p.rate_limited_until === null) {
134
+ out.rate_limited_until = null;
135
+ }
128
136
  }
129
- return { version: 1, sessions: {} };
137
+ return out;
130
138
  }
131
139
  /** Atomic write: write to a temp file then rename over the target. */
132
140
  export function writeState(state, dir, host, token) {
package/dist/sync.d.ts CHANGED
@@ -24,6 +24,8 @@ export interface SyncOptions {
24
24
  verbose: boolean;
25
25
  projectId: string | null;
26
26
  projectIdSource?: ProjectResolution["source"];
27
+ /** Mirrors StoredCredentials.insecureHttpAllowed — set when `init --insecure` was used for this host. */
28
+ allowInsecureHttp?: boolean;
27
29
  pricing: PricingTable;
28
30
  appDir?: string;
29
31
  transcriptBaseDirs?: string[];
@@ -65,6 +67,15 @@ export declare function getSyncTelemetry(): {
65
67
  lastResult: SyncResult | null;
66
68
  recentErrors: string[];
67
69
  };
70
+ /**
71
+ * The repo path for a Cursor payload, normalized and safe to hand to project
72
+ * resolution. Returns an absolute, `..`-collapsed path or undefined.
73
+ *
74
+ * `workspace_folder` comes from the workspace's own `workspace.json` and
75
+ * `workspace` from a composer `uri.fsPath` or the `state.vscdb` path — all
76
+ * untrusted. When the preferred field is unusable we fall through to the other
77
+ * rather than giving up. See DB90DV-547.
78
+ */
68
79
  export declare function cursorRepoPathFromPayload(payload: CursorDb90Payload): string | undefined;
69
80
  /**
70
81
  * Parallel multi-tool cycle under the global advisory ingest lock (`state.lock`).
package/dist/sync.js CHANGED
@@ -11,6 +11,7 @@ import { postEvent, postEvents } from "./client.js";
11
11
  import { getCostWarning } from "./pricing.js";
12
12
  import { acquireSyncLock } from "./lock.js";
13
13
  import { getGitRemoteForPath, lookupProjectByRemote, } from "./lib/index.js";
14
+ import { isRepoPathWithinRoot, normalizeRepoPathCandidate, } from "./lib/repo-path-safety.js";
14
15
  import { mcpLog } from "./log.js";
15
16
  /** Prefix for Claude Code session keys in shared MCP state. */
16
17
  export const CLAUDE_STATE_PREFIX = "claude_code:";
@@ -102,9 +103,9 @@ function explicitProjectId(projectId, projectIdSource) {
102
103
  ? projectId
103
104
  : undefined;
104
105
  }
105
- async function resolveProjectIdForRepoPathCached(repoPath, host, token, verbose, cache) {
106
- const normalized = repoPath?.trim();
107
- if (!normalized)
106
+ async function resolveProjectIdForRepoPathCached(repoPath, host, token, verbose, cache, allowInsecureHttp = false) {
107
+ const normalized = normalizeRepoPathCandidate(repoPath);
108
+ if (normalized === null)
108
109
  return null;
109
110
  if (cache.has(normalized))
110
111
  return cache.get(normalized) ?? null;
@@ -113,25 +114,35 @@ async function resolveProjectIdForRepoPathCached(repoPath, host, token, verbose,
113
114
  cache.set(normalized, null);
114
115
  return null;
115
116
  }
116
- const result = await lookupProjectByRemote(canonicalRemote, host, token, verbose);
117
+ const result = await lookupProjectByRemote(canonicalRemote, host, token, verbose, allowInsecureHttp);
117
118
  const projectId = result && typeof result === "object" && "project_id" in result ? result.project_id : null;
118
119
  cache.set(normalized, projectId);
119
120
  return projectId;
120
121
  }
122
+ /**
123
+ * The repo path for a Cursor payload, normalized and safe to hand to project
124
+ * resolution. Returns an absolute, `..`-collapsed path or undefined.
125
+ *
126
+ * `workspace_folder` comes from the workspace's own `workspace.json` and
127
+ * `workspace` from a composer `uri.fsPath` or the `state.vscdb` path — all
128
+ * untrusted. When the preferred field is unusable we fall through to the other
129
+ * rather than giving up. See DB90DV-547.
130
+ */
121
131
  export function cursorRepoPathFromPayload(payload) {
122
132
  const metadata = payload.metadata;
123
133
  if (!metadata)
124
134
  return undefined;
125
- if (typeof metadata.workspace_folder === "string" && metadata.workspace_folder.length > 0) {
126
- return metadata.workspace_folder;
127
- }
128
- if (typeof metadata.workspace === "string" && metadata.workspace.length > 0) {
129
- return metadata.workspace;
135
+ for (const candidate of [metadata.workspace_folder, metadata.workspace]) {
136
+ if (typeof candidate !== "string")
137
+ continue;
138
+ const normalized = normalizeRepoPathCandidate(candidate);
139
+ if (normalized !== null)
140
+ return normalized;
130
141
  }
131
142
  return undefined;
132
143
  }
133
144
  async function runClaudeSlice(options) {
134
- const { token, host, dryRun, verbose, projectId, pricing } = options;
145
+ const { token, host, dryRun, verbose, projectId, pricing, allowInsecureHttp = false } = options;
135
146
  const appDir = options.appDir ?? getAppDir();
136
147
  const backoffKey = credentialStateKey(host, token);
137
148
  const errors = [];
@@ -194,21 +205,27 @@ async function runClaudeSlice(options) {
194
205
  mcpLog.info("sync_noise_skip", { tool: "claude_code", reason: "local_command_noise" }, false);
195
206
  continue;
196
207
  }
197
- // When scopeDir is set, skip turns from other directories.
208
+ // When scopeDir is set, skip turns from other directories. `turn.cwd` is an
209
+ // arbitrary string from a transcript JSONL, so a plain prefix match would
210
+ // accept `<scopeDir>/../../elsewhere` (DB90DV-547).
198
211
  if (scopeDir) {
199
- const cwd = turn.cwd?.trim();
200
- const inScope = cwd && (cwd === scopeDir || cwd.startsWith(scopeDir + "/"));
212
+ const cwd = normalizeRepoPathCandidate(turn.cwd);
213
+ const inScope = cwd !== null && isRepoPathWithinRoot(cwd, scopeDir);
201
214
  if (!inScope) {
202
215
  totalSkipped++;
203
216
  if (verbose) {
204
- console.log(`[verbose] Skipping Claude turn ${turn.turnId} — cwd=${cwd ?? "(none)"} not under scopeDir=${scopeDir}`);
217
+ console.log(`[verbose] Skipping Claude turn ${turn.turnId} — cwd=${turn.cwd ?? "(none)"} not under scopeDir=${scopeDir}`);
205
218
  }
206
219
  continue;
207
220
  }
208
221
  }
209
222
  const sKey = sessionStateKey(turn.turnId);
210
223
  const known = state.sessions[sKey];
211
- if (known) {
224
+ // A turn keeps its turnId as Claude appends more tool_use blocks to it, so a
225
+ // plain "already known → skip" would drop derivatives appended after an earlier
226
+ // mid-turn sync. Skip only when the content fingerprint is unchanged; otherwise
227
+ // re-emit so the newly appended tool uses are sent (DB90DV-259).
228
+ if (known && known.contentHash && known.contentHash === turn.contentHash) {
212
229
  totalSkipped++;
213
230
  if (verbose) {
214
231
  console.log(`[verbose] Skipping already-synced Claude turn ${turn.turnId}`);
@@ -222,13 +239,16 @@ async function runClaudeSlice(options) {
222
239
  // effectively one network call per unique cwd per sync.
223
240
  const resolvedProjectId = scopeDir
224
241
  ? (projectId ??
225
- (await resolveProjectIdForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache)) ??
242
+ (await resolveProjectIdForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache, allowInsecureHttp)) ??
226
243
  undefined)
227
244
  : (explicitProject ??
228
- (await resolveProjectIdForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache)) ??
245
+ (await resolveProjectIdForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache, allowInsecureHttp)) ??
229
246
  undefined);
230
- const payload = mapClaudeTranscriptTurn(turn, { projectId: resolvedProjectId, pricing });
231
- if (verbose && payload.cost_usd === null) {
247
+ const payloads = mapClaudeTranscriptTurn(turn, { projectId: resolvedProjectId, pricing });
248
+ if (!payloads?.length)
249
+ continue;
250
+ const parentPayload = payloads[0];
251
+ if (verbose && parentPayload?.cost_usd === null) {
232
252
  if (!turn.model) {
233
253
  if (turn.tokensIn > 0 || turn.tokensOut > 0) {
234
254
  console.warn(`[warn] Claude turn ${turn.turnId} has usage but no model — cost_usd will be null`);
@@ -241,45 +261,54 @@ async function runClaudeSlice(options) {
241
261
  }
242
262
  }
243
263
  if (dryRun) {
244
- console.log(`[dry-run] Would send Claude turn ${turn.turnId}:`);
245
- console.log(JSON.stringify(payload, null, 2));
246
- totalSent++;
264
+ for (const payload of payloads) {
265
+ console.log(`[dry-run] Would send Claude ${payload.event_type} ${payload.metadata.session_id}:`);
266
+ console.log(JSON.stringify(payload, null, 2));
267
+ }
268
+ totalSent += payloads.length;
247
269
  continue;
248
270
  }
249
- if (verbose) {
250
- console.log(`[verbose] Sending Claude turn ${turn.turnId} (${turn.tokensIn + turn.tokensOut} tokens)`);
251
- }
252
- const ok = await postEvent(payload, host, token, {
253
- on429: (retryAfter, quotaExceeded) => {
254
- const currentBackoff = backoffUntilByCredential.get(backoffKey);
255
- const nextBackoff = new Date(Math.max(currentBackoff?.getTime() ?? 0, Date.now() + retryAfter * 1000));
256
- backoffUntilByCredential.set(backoffKey, nextBackoff);
257
- shouldStopForBackoff = true;
258
- const reason = quotaExceeded ? "Monthly quota exceeded" : "Rate limited";
259
- mcpLog.warn("sync_rate_limit_pause", {
260
- tool: "claude_code",
261
- retry_until: nextBackoff.toISOString(),
262
- quota_exceeded: quotaExceeded,
263
- }, true);
264
- console.warn(`[aixle-insights] ${reason}. Pausing until ${nextBackoff.toISOString()}.`);
265
- // Persist backoff to state so it survives process restarts
266
- state = { ...state, rate_limited_until: nextBackoff.toISOString() };
267
- writeState(state, appDir, host, token);
268
- },
269
- });
270
- if (ok) {
271
- state = markSessionSent(state, sKey, turn.fileSize);
272
- writeState(state, appDir, host, token);
273
- totalSent++;
274
- }
275
- else {
276
- totalFailed++;
277
- errors.push(`Failed to post Claude turn ${turn.turnId}`);
278
- mcpLog.error("sync_ingest_final_failure", { tool: "claude_code", session_id: turn.turnId }, true);
279
- if (shouldStopForBackoff) {
271
+ let allOk = true;
272
+ for (const payload of payloads) {
273
+ if (verbose) {
274
+ console.log(`[verbose] Sending Claude ${payload.event_type} ${payload.metadata.session_id}`);
275
+ }
276
+ const ok = await postEvent(payload, host, token, {
277
+ allowInsecureHttp,
278
+ on429: (retryAfter, quotaExceeded) => {
279
+ const currentBackoff = backoffUntilByCredential.get(backoffKey);
280
+ const nextBackoff = new Date(Math.max(currentBackoff?.getTime() ?? 0, Date.now() + retryAfter * 1000));
281
+ backoffUntilByCredential.set(backoffKey, nextBackoff);
282
+ shouldStopForBackoff = true;
283
+ const reason = quotaExceeded ? "Monthly quota exceeded" : "Rate limited";
284
+ mcpLog.warn("sync_rate_limit_pause", {
285
+ tool: "claude_code",
286
+ retry_until: nextBackoff.toISOString(),
287
+ quota_exceeded: quotaExceeded,
288
+ }, true);
289
+ console.warn(`[aixle-insights] ${reason}. Pausing until ${nextBackoff.toISOString()}.`);
290
+ // Persist backoff to state so it survives process restarts
291
+ state = { ...state, rate_limited_until: nextBackoff.toISOString() };
292
+ writeState(state, appDir, host, token);
293
+ },
294
+ });
295
+ if (!ok) {
296
+ allOk = false;
297
+ totalFailed++;
298
+ errors.push(`Failed to post Claude ${payload.event_type} for turn ${turn.turnId}`);
299
+ mcpLog.error("sync_ingest_final_failure", { tool: "claude_code", session_id: turn.turnId }, true);
280
300
  break;
281
301
  }
282
302
  }
303
+ if (allOk) {
304
+ totalSent += payloads.length;
305
+ state = markSessionSent(state, sKey, turn.fileSize, turn.contentHash);
306
+ writeState(state, appDir, host, token);
307
+ }
308
+ else if (shouldStopForBackoff) {
309
+ break;
310
+ }
311
+ // Non-backoff failure: continue to next turn (failed turn will retry next sync)
283
312
  }
284
313
  const result = { sent: totalSent, failed: totalFailed, skipped: totalSkipped };
285
314
  const currentBackoff = backoffUntilByCredential.get(backoffKey);
@@ -290,7 +319,7 @@ async function runClaudeSlice(options) {
290
319
  return result;
291
320
  }
292
321
  async function runCursorSlice(params) {
293
- const { token, host, dryRun, verbose, projectId, projectIdSource, projectLookupToken, appDir, cursorBaseDir, cursorTranscriptProjectDirs, scopeDir, fullScan = false, cursorPricing = resolveCursorPricing(undefined, appDir), } = params;
322
+ const { token, host, dryRun, verbose, projectId, projectIdSource, projectLookupToken, allowInsecureHttp = false, appDir, cursorBaseDir, cursorTranscriptProjectDirs, scopeDir, fullScan = false, cursorPricing = resolveCursorPricing(undefined, appDir), } = params;
294
323
  const backoffKey = credentialStateKey(host, token);
295
324
  // Read state first so we can restore a persisted rate-limit backoff from a prior process.
296
325
  const stateBefore = readState(appDir, host, token);
@@ -331,6 +360,7 @@ async function runCursorSlice(params) {
331
360
  host,
332
361
  token,
333
362
  projectLookupToken: lookupToken,
363
+ allowInsecureHttp,
334
364
  verbose,
335
365
  cursorBaseDir,
336
366
  cursorTranscriptProjectDirs,
@@ -341,11 +371,11 @@ async function runCursorSlice(params) {
341
371
  const inScope = [];
342
372
  for (const payload of group.payloads) {
343
373
  const ws = cursorRepoPathFromPayload(payload);
344
- if (ws && (ws === scopeDir || ws.startsWith(scopeDir + "/"))) {
374
+ if (ws && isRepoPathWithinRoot(ws, scopeDir)) {
345
375
  // Same fallback as Claude: when the pre-resolved projectId is null, do a
346
376
  // per-payload lookup from the payload's workspace. Cache dedupes by path.
347
377
  const resolved = projectId ??
348
- (await resolveProjectIdForRepoPathCached(ws, host, lookupToken, verbose, projectLookupCache));
378
+ (await resolveProjectIdForRepoPathCached(ws, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp));
349
379
  if (resolved)
350
380
  payload.project_id = resolved;
351
381
  else
@@ -365,7 +395,7 @@ async function runCursorSlice(params) {
365
395
  continue;
366
396
  for (const payload of group.payloads) {
367
397
  const repoPath = cursorRepoPathFromPayload(payload);
368
- const resolvedProjectId = await resolveProjectIdForRepoPathCached(repoPath, host, lookupToken, verbose, projectLookupCache);
398
+ const resolvedProjectId = await resolveProjectIdForRepoPathCached(repoPath, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp);
369
399
  if (resolvedProjectId)
370
400
  payload.project_id = resolvedProjectId;
371
401
  else
@@ -438,7 +468,7 @@ async function runCursorSlice(params) {
438
468
  totalSkipped++;
439
469
  continue;
440
470
  }
441
- const ok = await postEvent(payload, host, token, { on429 });
471
+ const ok = await postEvent(payload, host, token, { on429, allowInsecureHttp });
442
472
  if (ok) {
443
473
  totalSent++;
444
474
  const turnId = payload.metadata.session_id;
@@ -455,7 +485,7 @@ async function runCursorSlice(params) {
455
485
  }
456
486
  continue;
457
487
  }
458
- const batchResult = await postEvents(group.payloads, host, token, { on429 });
488
+ const batchResult = await postEvents(group.payloads, host, token, { on429, allowInsecureHttp });
459
489
  totalSent += batchResult.sent;
460
490
  totalFailed += batchResult.failed;
461
491
  if (batchResult.failed > 0) {
@@ -501,10 +531,11 @@ async function runCursorSlice(params) {
501
531
  state: stateMut,
502
532
  host,
503
533
  token,
534
+ allowInsecureHttp,
504
535
  on429,
505
536
  resolveProjectId: explicitProject
506
537
  ? undefined
507
- : (workspace) => resolveProjectIdForRepoPathCached(workspace, host, lookupToken, verbose, projectLookupCache),
538
+ : (workspace) => resolveProjectIdForRepoPathCached(workspace, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp),
508
539
  verbose,
509
540
  });
510
541
  totalSent += hooksResult.sent;
@@ -590,6 +621,7 @@ export async function syncTelemetryTools(options) {
590
621
  dryRun,
591
622
  verbose,
592
623
  projectId,
624
+ allowInsecureHttp: credentials.insecureHttpAllowed === true,
593
625
  pricing,
594
626
  appDir,
595
627
  transcriptBaseDirs: options.transcriptBaseDirs,
@@ -606,6 +638,7 @@ export async function syncTelemetryTools(options) {
606
638
  projectId,
607
639
  projectIdSource,
608
640
  projectLookupToken,
641
+ allowInsecureHttp: credentials.insecureHttpAllowed === true,
609
642
  appDir,
610
643
  cursorBaseDir: options.cursorBaseDir,
611
644
  cursorTranscriptProjectDirs: options.cursorTranscriptProjectDirs,