impel-cli 0.16.4 → 0.17.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.
@@ -0,0 +1,996 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import * as zlib from "node:zlib";
7
+
8
+ import { CONFIG_DIR, loadConfig, redactCredentialText, redactSecretText } from "./config.js";
9
+
10
+ export const DEFAULT_SESSIONS_URL = "https://sessions.useimpel.com";
11
+ const MAX_HOOK_INPUT_BYTES = 2 * 1024 * 1024;
12
+ const MAX_LEDGER_PAYLOAD_BYTES = 256 * 1024;
13
+ const TRANSCRIPT_CHUNK_BYTES = 512 * 1024;
14
+ const MAX_TRANSCRIPT_CAPTURE_BYTES = 4 * 1024 * 1024;
15
+ const MAX_FLUSH_BATCHES = 8;
16
+ const DEFAULT_MAX_OUTBOX_BYTES = 256 * 1024 * 1024;
17
+ const DEFAULT_MAX_OUTBOX_BATCHES = 2048;
18
+ const MAX_QUARANTINED_BATCHES = 32;
19
+ const MAX_BATCH_FAILURES = 3;
20
+ const REQUEST_TIMEOUT_MS = 30_000;
21
+ const DIRECT_UPLOAD_TIMEOUT_MS = 30_000;
22
+ const SAFE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u;
23
+ const TASK_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
24
+
25
+ function stateRoot() {
26
+ return process.env.IMPEL_SESSIONS_STATE_DIR || path.join(CONFIG_DIR, "sessions");
27
+ }
28
+
29
+ function sha256(value) {
30
+ return crypto.createHash("sha256").update(value).digest("hex");
31
+ }
32
+
33
+ function stableUuid(namespace) {
34
+ const bytes = crypto.createHash("sha256").update(namespace, "utf8").digest().subarray(0, 16);
35
+ bytes[6] = (bytes[6] & 0x0f) | 0x50;
36
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
37
+ const hex = bytes.toString("hex");
38
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
39
+ }
40
+
41
+ function privateDirectory(directory) {
42
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
43
+ try {
44
+ fs.chmodSync(directory, 0o700);
45
+ } catch {
46
+ // Best effort on platforms where chmod is unavailable.
47
+ }
48
+ }
49
+
50
+ function atomicWrite(filePath, contents) {
51
+ privateDirectory(path.dirname(filePath));
52
+ const temporaryPath = `${filePath}.tmp-${process.pid}-${crypto.randomUUID()}`;
53
+ try {
54
+ fs.writeFileSync(temporaryPath, contents, { mode: 0o600 });
55
+ fs.renameSync(temporaryPath, filePath);
56
+ } finally {
57
+ try {
58
+ fs.rmSync(temporaryPath, { force: true });
59
+ } catch {
60
+ // The successful rename already removed the temporary file.
61
+ }
62
+ }
63
+ }
64
+
65
+ function readJson(filePath, fallback = null) {
66
+ try {
67
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
68
+ } catch {
69
+ return fallback;
70
+ }
71
+ }
72
+
73
+ function positiveIntegerEnvironment(name, fallback) {
74
+ const raw = process.env[name];
75
+ if (raw === undefined || raw === "") return fallback;
76
+ const value = Number(raw);
77
+ return Number.isSafeInteger(value) && value > 0 ? value : fallback;
78
+ }
79
+
80
+ function safeSegment(value, fallback) {
81
+ const normalized = String(value || "")
82
+ .normalize("NFC")
83
+ .replace(/[^A-Za-z0-9._:-]+/gu, "-")
84
+ .replace(/^-+|-+$/gu, "")
85
+ .slice(0, 96);
86
+ return normalized || fallback;
87
+ }
88
+
89
+ function sessionDirectory(tenantId, provider, sessionKey) {
90
+ return path.join(
91
+ stateRoot(),
92
+ "sessions",
93
+ safeSegment(tenantId, "tenant"),
94
+ safeSegment(provider, "unknown-provider"),
95
+ sha256(sessionKey).slice(0, 32),
96
+ );
97
+ }
98
+
99
+ function writeError(root, error) {
100
+ const message = redactSecretText(error?.message || error).slice(0, 2048);
101
+ atomicWrite(path.join(root, "last-error.json"), `${JSON.stringify({ at: new Date().toISOString(), message }, null, 2)}\n`);
102
+ }
103
+
104
+ function clearError(root) {
105
+ try {
106
+ fs.rmSync(path.join(root, "last-error.json"), { force: true });
107
+ } catch {
108
+ // Best effort only.
109
+ }
110
+ }
111
+
112
+ function lockOwnerAlive(owner) {
113
+ if (!Number.isInteger(owner?.pid) || owner.pid < 1) return false;
114
+ try {
115
+ process.kill(owner.pid, 0);
116
+ return true;
117
+ } catch (error) {
118
+ return error?.code === "EPERM";
119
+ }
120
+ }
121
+
122
+ function acquireLock(root, name, staleMs = 60_000) {
123
+ const lock = path.join(root, `${name}.lock`);
124
+ const takeover = `${lock}.takeover`;
125
+ const token = crypto.randomUUID();
126
+ privateDirectory(root);
127
+ const takeoverOwner = readJson(takeover);
128
+ if (takeoverOwner || fs.existsSync(takeover)) {
129
+ let age = 0;
130
+ try { age = Date.now() - fs.statSync(takeover).mtimeMs; } catch { /* Re-check through the create below. */ }
131
+ if ((takeoverOwner && lockOwnerAlive(takeoverOwner)) || age <= staleMs) return null;
132
+ try { fs.rmSync(takeover, { force: true }); } catch { return null; }
133
+ }
134
+ try {
135
+ fs.mkdirSync(lock, { mode: 0o700 });
136
+ if (fs.existsSync(takeover)) {
137
+ fs.rmSync(lock, { recursive: true, force: true });
138
+ return null;
139
+ }
140
+ atomicWrite(path.join(lock, "owner.json"), `${JSON.stringify({ token, pid: process.pid, at: Date.now() })}\n`);
141
+ return () => {
142
+ const current = readJson(path.join(lock, "owner.json"));
143
+ if (current?.token === token) fs.rmSync(lock, { recursive: true, force: true });
144
+ };
145
+ } catch (error) {
146
+ if (error?.code !== "EEXIST") throw error;
147
+ }
148
+ const owner = readJson(path.join(lock, "owner.json"));
149
+ if (lockOwnerAlive(owner)) return null;
150
+ let lockAge = 0;
151
+ try {
152
+ lockAge = Date.now() - fs.statSync(lock).mtimeMs;
153
+ } catch (error) {
154
+ if (error?.code === "ENOENT") return acquireLock(root, name, staleMs);
155
+ throw error;
156
+ }
157
+ const age = owner?.at ? Date.now() - owner.at : lockAge;
158
+ if (age <= staleMs) return null;
159
+
160
+ let takeoverDescriptor;
161
+ try {
162
+ takeoverDescriptor = fs.openSync(takeover, "wx", 0o600);
163
+ fs.writeFileSync(takeoverDescriptor, `${JSON.stringify({ token, pid: process.pid, at: Date.now() })}\n`);
164
+ } catch (error) {
165
+ if (takeoverDescriptor !== undefined) fs.closeSync(takeoverDescriptor);
166
+ if (error?.code === "EEXIST") return null;
167
+ throw error;
168
+ }
169
+ fs.closeSync(takeoverDescriptor);
170
+ try {
171
+ const currentOwner = readJson(path.join(lock, "owner.json"));
172
+ const unchanged = owner?.token ? currentOwner?.token === owner.token : !currentOwner?.token;
173
+ if (!unchanged || lockOwnerAlive(currentOwner)) return null;
174
+
175
+ // Move the stale candidate aside atomically while the takeover file keeps
176
+ // other acquirers from creating a successor at the original path.
177
+ const tombstone = `${lock}.stale-${process.pid}-${crypto.randomUUID()}`;
178
+ try {
179
+ fs.renameSync(lock, tombstone);
180
+ } catch (error) {
181
+ if (error?.code === "ENOENT") return null;
182
+ throw error;
183
+ }
184
+ const movedOwner = readJson(path.join(tombstone, "owner.json"));
185
+ const sameOwner = owner?.token ? movedOwner?.token === owner.token : !movedOwner?.token;
186
+ if (!sameOwner || lockOwnerAlive(movedOwner)) {
187
+ fs.renameSync(tombstone, lock);
188
+ return null;
189
+ }
190
+ fs.rmSync(tombstone, { recursive: true, force: true });
191
+ } finally {
192
+ fs.rmSync(takeover, { force: true });
193
+ }
194
+ return acquireLock(root, name, staleMs);
195
+ }
196
+
197
+ function providerRoots(provider) {
198
+ const roots = [process.env.IMPEL_SESSION_TRANSCRIPT_ROOT];
199
+ if (provider === "claude_code") roots.push(process.env.CLAUDE_CONFIG_DIR, path.join(os.homedir(), ".claude"));
200
+ else roots.push(process.env.CODEX_HOME, path.join(os.homedir(), ".codex"));
201
+ return roots.filter(Boolean).map((candidate) => {
202
+ const resolved = path.resolve(candidate);
203
+ try {
204
+ return fs.realpathSync(resolved);
205
+ } catch {
206
+ return resolved;
207
+ }
208
+ });
209
+ }
210
+
211
+ function allowedTranscript(filePath, provider) {
212
+ if (typeof filePath !== "string" || !path.isAbsolute(filePath)) return null;
213
+ let stat;
214
+ let real;
215
+ try {
216
+ const linkStat = fs.lstatSync(filePath);
217
+ if (linkStat.isSymbolicLink() || !linkStat.isFile()) return null;
218
+ real = fs.realpathSync(filePath);
219
+ stat = fs.statSync(real);
220
+ } catch {
221
+ return null;
222
+ }
223
+ const allowed = providerRoots(provider).some((root) => real === root || real.startsWith(`${root}${path.sep}`));
224
+ return allowed ? { real, stat } : null;
225
+ }
226
+
227
+ function compactLedgerPayload(input) {
228
+ const encoded = redactCredentialText(JSON.stringify(input));
229
+ const sanitized = JSON.parse(encoded);
230
+ if (Buffer.byteLength(encoded) <= MAX_LEDGER_PAYLOAD_BYTES) return { payload: sanitized, payloadTruncated: false };
231
+ const keys = [
232
+ "session_id", "hook_event_name", "cwd", "permission_mode", "model", "source", "reason",
233
+ "turn_id", "prompt_id", "agent_id", "agent_type", "tool_name", "tool_use_id", "error",
234
+ "error_details", "last_assistant_message",
235
+ ];
236
+ const payload = {};
237
+ for (const key of keys) {
238
+ if (!Object.hasOwn(sanitized, key)) continue;
239
+ const value = sanitized[key];
240
+ payload[key] = typeof value === "string" && value.length > 131_072
241
+ ? `${value.slice(0, 131_072)}\n[truncated]`
242
+ : value;
243
+ }
244
+ return { payload, payloadTruncated: true, originalSha256: sha256(encoded) };
245
+ }
246
+
247
+ function transcriptBatch(descriptor, offset, fileSize, discardUntilNewline = false) {
248
+ const pieces = [];
249
+ const digest = crypto.createHash("sha256");
250
+ let accumulated = 0;
251
+ let scanOffset = offset;
252
+ while (scanOffset < fileSize && accumulated < MAX_TRANSCRIPT_CAPTURE_BYTES) {
253
+ const wanted = Math.min(
254
+ TRANSCRIPT_CHUNK_BYTES,
255
+ fileSize - scanOffset,
256
+ MAX_TRANSCRIPT_CAPTURE_BYTES - accumulated,
257
+ );
258
+ const buffer = Buffer.allocUnsafe(wanted);
259
+ const bytesRead = fs.readSync(descriptor, buffer, 0, wanted, scanOffset);
260
+ if (bytesRead < 1) break;
261
+ const chunk = buffer.subarray(0, bytesRead);
262
+ const newline = pieces.length === 0 && !discardUntilNewline
263
+ ? chunk.lastIndexOf(0x0a)
264
+ : chunk.indexOf(0x0a);
265
+ const consumed = newline >= 0 ? chunk.subarray(0, newline + 1) : chunk;
266
+ scanOffset += consumed.length;
267
+ accumulated += consumed.length;
268
+
269
+ if (discardUntilNewline) {
270
+ digest.update(consumed);
271
+ } else {
272
+ pieces.push(consumed);
273
+ }
274
+
275
+ if (newline >= 0) {
276
+ if (discardUntilNewline) {
277
+ return {
278
+ payload: null,
279
+ bytesConsumed: scanOffset - offset,
280
+ itemCount: 0,
281
+ discardUntilNewline: false,
282
+ };
283
+ }
284
+ const raw = Buffer.concat(pieces);
285
+ const redacted = Buffer.from(redactCredentialText(raw.toString("utf8")), "utf8");
286
+ const itemCount = redacted.reduce((count, byte) => count + (byte === 0x0a ? 1 : 0), 0);
287
+ return { payload: redacted, bytesConsumed: scanOffset - offset, itemCount, discardUntilNewline: false };
288
+ }
289
+ }
290
+ if (accumulated >= MAX_TRANSCRIPT_CAPTURE_BYTES) {
291
+ if (discardUntilNewline) {
292
+ return {
293
+ payload: null,
294
+ bytesConsumed: scanOffset - offset,
295
+ itemCount: 0,
296
+ discardUntilNewline: true,
297
+ };
298
+ }
299
+ for (const piece of pieces) digest.update(piece);
300
+ const placeholder = Buffer.from(`${JSON.stringify({
301
+ type: "impel_oversized_transcript_record",
302
+ capturedPrefixBytes: accumulated,
303
+ capturedPrefixSha256: digest.digest("hex"),
304
+ omitted: true,
305
+ })}\n`, "utf8");
306
+ return {
307
+ payload: placeholder,
308
+ bytesConsumed: scanOffset - offset,
309
+ itemCount: 1,
310
+ discardUntilNewline: true,
311
+ };
312
+ }
313
+ return null;
314
+ }
315
+
316
+ function spoolBatch(root, sessionMeta, stream, payload, itemCount) {
317
+ if (!Buffer.isBuffer(payload) || payload.length === 0 || itemCount < 1) return null;
318
+ const batchId = `b-${Date.now().toString(36)}-${crypto.randomUUID()}`;
319
+ const pendingRoot = path.join(root, "pending");
320
+ privateDirectory(pendingRoot);
321
+ const temporary = fs.mkdtempSync(path.join(pendingRoot, ".tmp-"));
322
+ const finalDirectory = path.join(pendingRoot, batchId);
323
+ const manifest = {
324
+ schemaVersion: 1,
325
+ batchId,
326
+ createdAt: new Date().toISOString(),
327
+ expectedSequence: null,
328
+ itemCount,
329
+ rawBytes: payload.length,
330
+ rawSha256: sha256(payload),
331
+ session: sessionMeta,
332
+ stream,
333
+ };
334
+ try {
335
+ fs.writeFileSync(path.join(temporary, "payload"), payload, { mode: 0o600 });
336
+ fs.writeFileSync(path.join(temporary, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
337
+ fs.renameSync(temporary, finalDirectory);
338
+ } catch (error) {
339
+ fs.rmSync(temporary, { recursive: true, force: true });
340
+ throw error;
341
+ }
342
+ return finalDirectory;
343
+ }
344
+
345
+ function batchDirectoryBytes(directory) {
346
+ let total = 0;
347
+ for (const name of ["manifest.json", "payload", "failure.json"]) {
348
+ try {
349
+ total += fs.statSync(path.join(directory, name)).size;
350
+ } catch {
351
+ // Missing or concurrently moved files do not contribute to the estimate.
352
+ }
353
+ }
354
+ return total;
355
+ }
356
+
357
+ function recordOutboxDrop(root, droppedBatches, droppedBytes, reason) {
358
+ if (droppedBatches === 0) return;
359
+ const noticePath = path.join(root, "outbox-drops.json");
360
+ const prior = readJson(noticePath, { droppedBatches: 0, droppedBytes: 0 });
361
+ atomicWrite(noticePath, `${JSON.stringify({
362
+ droppedBatches: Number(prior.droppedBatches || 0) + droppedBatches,
363
+ droppedBytes: Number(prior.droppedBytes || 0) + droppedBytes,
364
+ lastReason: reason,
365
+ updatedAt: new Date().toISOString(),
366
+ }, null, 2)}\n`);
367
+ }
368
+
369
+ function enforceOutboxLimits(root) {
370
+ const release = acquireLock(root, "flush");
371
+ if (!release) {
372
+ return { dropped: 0, pending: pendingDirectories(root).length, bytes: null, deferred: true };
373
+ }
374
+ try {
375
+ const maximumBytes = positiveIntegerEnvironment("IMPEL_SESSIONS_MAX_OUTBOX_BYTES", DEFAULT_MAX_OUTBOX_BYTES);
376
+ const maximumBatches = positiveIntegerEnvironment("IMPEL_SESSIONS_MAX_OUTBOX_BATCHES", DEFAULT_MAX_OUTBOX_BATCHES);
377
+ const directories = pendingDirectories(root);
378
+ let bytes = directories.reduce((total, directory) => total + batchDirectoryBytes(directory), 0);
379
+ let count = directories.length;
380
+ const dropped = [];
381
+ let droppedBytes = 0;
382
+ for (const directory of directories) {
383
+ if (count <= maximumBatches && bytes <= maximumBytes) break;
384
+ const size = batchDirectoryBytes(directory);
385
+ try {
386
+ fs.rmSync(directory, { recursive: true, force: true });
387
+ dropped.push(directory);
388
+ droppedBytes += size;
389
+ count -= 1;
390
+ bytes = Math.max(0, bytes - size);
391
+ } catch {
392
+ // The batch disappeared before maintenance acquired its exclusive lock.
393
+ }
394
+ }
395
+ recordOutboxDrop(root, dropped.length, droppedBytes, "local outbox safety limit exceeded; oldest pending batches were evicted");
396
+ return { dropped: dropped.length, pending: count, bytes };
397
+ } finally {
398
+ release();
399
+ }
400
+ }
401
+
402
+ function captureLedger(root, sessionMeta, input) {
403
+ const compact = compactLedgerPayload(input);
404
+ const event = {
405
+ schemaVersion: 1,
406
+ capturedAt: new Date().toISOString(),
407
+ provider: sessionMeta.provider,
408
+ surface: sessionMeta.surface,
409
+ sessionTreeId: sessionMeta.sessionTreeId,
410
+ event: String(input.hook_event_name || "unknown").slice(0, 128),
411
+ ...compact,
412
+ };
413
+ const payload = Buffer.from(`${JSON.stringify(event)}\n`, "utf8");
414
+ return spoolBatch(root, sessionMeta, {
415
+ kind: "hook_ledger",
416
+ subpath: sessionMeta.surface,
417
+ format: "ndjson",
418
+ }, payload, 1);
419
+ }
420
+
421
+ function captureTranscript(root, sessionMeta, input) {
422
+ const transcript = allowedTranscript(input.transcript_path, sessionMeta.provider);
423
+ if (!transcript) return 0;
424
+ const cursorsRoot = path.join(root, "cursors");
425
+ privateDirectory(cursorsRoot);
426
+ const cursorKey = sha256(transcript.real).slice(0, 32);
427
+ const cursorPath = path.join(cursorsRoot, `${cursorKey}.json`);
428
+ const prior = readJson(cursorPath, { offset: 0, generation: 0 });
429
+ let offset = Number.isSafeInteger(prior.offset) && prior.offset >= 0 ? prior.offset : 0;
430
+ let generation = Number.isSafeInteger(prior.generation) && prior.generation >= 0 ? prior.generation : 0;
431
+ let discardUntilNewline = prior.discardUntilNewline === true;
432
+ const identity = `${transcript.stat.dev}:${transcript.stat.ino}`;
433
+ if ((prior.identity && prior.identity !== identity) || transcript.stat.size < offset) {
434
+ offset = 0;
435
+ generation += 1;
436
+ discardUntilNewline = false;
437
+ }
438
+ let captured = 0;
439
+ let batches = 0;
440
+ const descriptor = fs.openSync(transcript.real, "r");
441
+ try {
442
+ while (offset < transcript.stat.size && captured < MAX_TRANSCRIPT_CAPTURE_BYTES) {
443
+ const batch = transcriptBatch(descriptor, offset, transcript.stat.size, discardUntilNewline);
444
+ if (!batch) break;
445
+ if (batch.payload) {
446
+ spoolBatch(root, sessionMeta, {
447
+ kind: "transcript",
448
+ subpath: `transcripts/${cursorKey}-g${generation}`,
449
+ format: "ndjson",
450
+ }, batch.payload, batch.itemCount);
451
+ batches += 1;
452
+ }
453
+ offset += batch.bytesConsumed;
454
+ captured += batch.bytesConsumed;
455
+ discardUntilNewline = batch.discardUntilNewline;
456
+ atomicWrite(cursorPath, `${JSON.stringify({
457
+ path: transcript.real,
458
+ identity,
459
+ offset,
460
+ generation,
461
+ ...(discardUntilNewline ? { discardUntilNewline: true } : {}),
462
+ updatedAt: new Date().toISOString(),
463
+ }, null, 2)}\n`);
464
+ if (captured >= MAX_TRANSCRIPT_CAPTURE_BYTES) break;
465
+ }
466
+ } finally {
467
+ fs.closeSync(descriptor);
468
+ }
469
+ return batches;
470
+ }
471
+
472
+ function gitValue(cwd, args) {
473
+ try {
474
+ const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 1500, stdio: ["ignore", "pipe", "ignore"] });
475
+ return result.status === 0 ? result.stdout.trim().slice(0, 2048) : "";
476
+ } catch {
477
+ return "";
478
+ }
479
+ }
480
+
481
+ function repositoryMetadata(cwd) {
482
+ if (typeof cwd !== "string" || !path.isAbsolute(cwd)) return {};
483
+ let repositoryUrl = gitValue(cwd, ["config", "--get", "remote.origin.url"]);
484
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//u.test(repositoryUrl)) {
485
+ try {
486
+ const url = new URL(repositoryUrl);
487
+ url.username = "";
488
+ url.password = "";
489
+ repositoryUrl = url.toString();
490
+ } catch {
491
+ repositoryUrl = redactSecretText(repositoryUrl);
492
+ }
493
+ } else if (/^[^/@]+@[^:]+:/u.test(repositoryUrl)) {
494
+ repositoryUrl = repositoryUrl.replace(/^[^@]+@/u, "git@");
495
+ }
496
+ return {
497
+ ...(repositoryUrl ? { repositoryUrl } : {}),
498
+ repositoryRef: gitValue(cwd, ["symbolic-ref", "--short", "-q", "HEAD"]),
499
+ repositoryCommit: gitValue(cwd, ["rev-parse", "HEAD"]),
500
+ };
501
+ }
502
+
503
+ function sessionsUrl(config) {
504
+ return String(process.env.IMPEL_SESSIONS_URL || config?.sessionsUrl || DEFAULT_SESSIONS_URL).trim().replace(/\/+$/u, "");
505
+ }
506
+
507
+ function problemCode(body) {
508
+ return body?.error?.code || body?.code || "";
509
+ }
510
+
511
+ function problemMessage(body) {
512
+ return body?.error?.message || body?.detail || "unknown error";
513
+ }
514
+
515
+ function requestHeaders(config, tenantId, extra = {}) {
516
+ const headers = { accept: "application/json", ...extra };
517
+ const devOrg = process.env.IMPEL_SESSIONS_DEV_ORG_ID;
518
+ if (devOrg) {
519
+ headers["x-impel-dev-org-id"] = devOrg;
520
+ headers["x-impel-dev-user-id"] = process.env.IMPEL_SESSIONS_DEV_USER_ID || "impel-cli-smoke";
521
+ } else {
522
+ if (!config?.pat) throw new Error("Impel authentication is unavailable");
523
+ headers.authorization = `Bearer ${config.pat}`;
524
+ headers["x-impel-org-id"] = tenantId;
525
+ }
526
+ const bypass = process.env.IMPEL_SESSIONS_VERCEL_BYPASS_TOKEN;
527
+ if (bypass) headers["x-vercel-protection-bypass"] = bypass;
528
+ return headers;
529
+ }
530
+
531
+ async function apiRequest(config, tenantId, route, options = {}) {
532
+ const controller = new AbortController();
533
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
534
+ try {
535
+ const response = await fetch(`${sessionsUrl(config)}${route}`, {
536
+ ...options,
537
+ headers: requestHeaders(config, tenantId, options.headers),
538
+ signal: controller.signal,
539
+ });
540
+ const text = await response.text();
541
+ let body = null;
542
+ if (text) {
543
+ try {
544
+ body = JSON.parse(text);
545
+ } catch {
546
+ body = { detail: text.slice(0, 1024) };
547
+ }
548
+ }
549
+ return { response, body };
550
+ } catch (error) {
551
+ const method = options.method || "GET";
552
+ throw new Error(`${method} ${route} failed: ${error?.name === "AbortError" ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : error?.message || error}`);
553
+ } finally {
554
+ clearTimeout(timer);
555
+ }
556
+ }
557
+
558
+ function compressZstd(payload) {
559
+ if (process.env.IMPEL_SESSIONS_DISABLE_DIRECT_UPLOAD === "1") return null;
560
+ if (typeof zlib.zstdCompressSync !== "function") return null;
561
+ const level = zlib.constants?.ZSTD_c_compressionLevel;
562
+ const windowLog = zlib.constants?.ZSTD_c_windowLog;
563
+ const params = {};
564
+ if (Number.isInteger(level)) params[level] = 3;
565
+ if (Number.isInteger(windowLog)) params[windowLog] = 20;
566
+ return zlib.zstdCompressSync(payload, Object.keys(params).length > 0 ? { params } : undefined);
567
+ }
568
+
569
+ async function directBlobPut(upload, payload) {
570
+ const controller = new AbortController();
571
+ const timer = setTimeout(() => controller.abort(), DIRECT_UPLOAD_TIMEOUT_MS);
572
+ try {
573
+ const response = await fetch(upload.url, {
574
+ method: upload.method || "PUT",
575
+ headers: upload.headers || {},
576
+ body: payload,
577
+ signal: controller.signal,
578
+ });
579
+ if (!response.ok) {
580
+ const detail = (await response.text()).slice(0, 1024);
581
+ throw new Error(`private Blob upload failed with HTTP ${response.status}: ${detail}`);
582
+ }
583
+ } catch (error) {
584
+ throw new Error(`private Blob upload failed: ${error?.name === "AbortError" ? `timed out after ${DIRECT_UPLOAD_TIMEOUT_MS}ms` : error?.message || error}`);
585
+ } finally {
586
+ clearTimeout(timer);
587
+ }
588
+ }
589
+
590
+ async function uploadDirect(config, tenantId, streamId, manifest, payload, compressed) {
591
+ const route = `/v1/streams/${streamId}/batches/${encodeURIComponent(manifest.batchId)}`;
592
+ const reserved = await apiRequest(config, tenantId, `${route}/upload`, {
593
+ method: "POST",
594
+ headers: { "content-type": "application/json" },
595
+ body: JSON.stringify({
596
+ expectedSequence: manifest.expectedSequence,
597
+ itemCount: manifest.itemCount,
598
+ rawBytes: payload.length,
599
+ storedBytes: compressed.length,
600
+ rawSha256: manifest.rawSha256,
601
+ storedSha256: sha256(compressed),
602
+ }),
603
+ });
604
+ if (reserved.response.ok && reserved.body?.batch) return reserved;
605
+ if (!reserved.response.ok) return reserved;
606
+
607
+ // A prior process may have uploaded the frame and died before recording the
608
+ // finalize response. Finalize first so that retry never needs blob overwrite.
609
+ let finalized = await apiRequest(config, tenantId, `${route}/finalize`, { method: "POST" });
610
+ if (finalized.response.ok) return finalized;
611
+ if (finalized.response.status !== 409 || problemCode(finalized.body) !== "upload_missing") return finalized;
612
+
613
+ await directBlobPut(reserved.body.upload, compressed);
614
+ finalized = await apiRequest(config, tenantId, `${route}/finalize`, { method: "POST" });
615
+ return finalized;
616
+ }
617
+
618
+ async function cancelDirectUpload(config, tenantId, streamId, batchId) {
619
+ const cancelled = await apiRequest(
620
+ config,
621
+ tenantId,
622
+ `/v1/streams/${streamId}/batches/${encodeURIComponent(batchId)}/upload`,
623
+ { method: "DELETE" },
624
+ );
625
+ if (cancelled.response.ok || [404, 410].includes(cancelled.response.status)) return;
626
+ throw new Error(`direct upload cancellation failed with HTTP ${cancelled.response.status}: ${problemMessage(cancelled.body)}`);
627
+ }
628
+
629
+ async function ensureRemoteSession(config, tenantId, manifest) {
630
+ const id = stableUuid(`impel-session:${tenantId}:${manifest.session.provider}:${manifest.session.sessionTreeId}`);
631
+ const metadata = repositoryMetadata(manifest.session.cwd);
632
+ const payload = {
633
+ id,
634
+ provider: manifest.session.provider,
635
+ providerVersion: manifest.session.providerVersion || "",
636
+ sessionTreeId: manifest.session.sessionTreeId,
637
+ providerThreadId: manifest.session.providerThreadId,
638
+ surface: manifest.session.surface,
639
+ visibility: "user",
640
+ persistenceState: "mirrored",
641
+ resumability: "portable_only",
642
+ completeness: "best_effort",
643
+ ...metadata,
644
+ };
645
+ const created = await apiRequest(config, tenantId, "/v1/sessions", {
646
+ method: "POST",
647
+ headers: { "content-type": "application/json" },
648
+ body: JSON.stringify(payload),
649
+ });
650
+ if (!created.response.ok && created.response.status !== 409) {
651
+ throw new Error(`session create failed with HTTP ${created.response.status}: ${problemMessage(created.body)}`);
652
+ }
653
+ if (created.response.status === 409) {
654
+ const existing = await apiRequest(config, tenantId, `/v1/sessions/${id}`);
655
+ if (!existing.response.ok) {
656
+ const error = new Error(`session lookup failed with HTTP ${existing.response.status}`);
657
+ error.permanentBatchFailure = existing.response.status === 404;
658
+ throw error;
659
+ }
660
+ const session = existing.body?.session;
661
+ if (session?.provider !== payload.provider || session?.sessionTreeId !== payload.sessionTreeId) {
662
+ const error = new Error("session ID conflict resolved to different provider identity");
663
+ error.permanentBatchFailure = true;
664
+ throw error;
665
+ }
666
+ }
667
+ return id;
668
+ }
669
+
670
+ async function linkTaskBestEffort(config, tenantId, root, sessionID, taskID) {
671
+ if (!taskID) return;
672
+ const errorPath = path.join(root, "task-link-error.json");
673
+ if (Array.isArray(config?.scopes) && !config.scopes.includes("tasks")) {
674
+ atomicWrite(errorPath, `${JSON.stringify({
675
+ at: new Date().toISOString(),
676
+ sessionId: sessionID,
677
+ taskId: taskID,
678
+ message: "Task link is pending because the current credential does not have the tasks scope",
679
+ }, null, 2)}\n`);
680
+ return;
681
+ }
682
+ try {
683
+ const linked = await apiRequest(
684
+ config,
685
+ tenantId,
686
+ `/v1/sessions/${sessionID}/tasks/${encodeURIComponent(taskID)}`,
687
+ { method: "PUT" },
688
+ );
689
+ if (!linked.response.ok) {
690
+ throw new Error(`task link failed with HTTP ${linked.response.status}: ${problemMessage(linked.body)}`);
691
+ }
692
+ fs.rmSync(errorPath, { force: true });
693
+ } catch (error) {
694
+ const message = redactSecretText(error?.message || error).slice(0, 1024);
695
+ atomicWrite(errorPath, `${JSON.stringify({
696
+ at: new Date().toISOString(),
697
+ sessionId: sessionID,
698
+ taskId: taskID,
699
+ message,
700
+ }, null, 2)}\n`);
701
+ }
702
+ }
703
+
704
+ async function ensureRemoteStream(config, tenantId, sessionId, stream) {
705
+ const streamId = stableUuid(`impel-stream:${sessionId}:${stream.kind}:${stream.subpath}:${stream.format}`);
706
+ const created = await apiRequest(config, tenantId, `/v1/sessions/${sessionId}/streams`, {
707
+ method: "POST",
708
+ headers: { "content-type": "application/json" },
709
+ body: JSON.stringify({ id: streamId, ...stream, requireLease: false }),
710
+ });
711
+ if (!created.response.ok && created.response.status !== 409) {
712
+ throw new Error(`stream create failed with HTTP ${created.response.status}: ${problemMessage(created.body)}`);
713
+ }
714
+ const current = await apiRequest(config, tenantId, `/v1/streams/${streamId}`);
715
+ if (!current.response.ok) throw new Error(`stream lookup failed with HTTP ${current.response.status}`);
716
+ return current.body.stream;
717
+ }
718
+
719
+ async function uploadPending(config, tenantId, directory, remote) {
720
+ const manifestPath = path.join(directory, "manifest.json");
721
+ const manifest = readJson(manifestPath);
722
+ if (!manifest || manifest.schemaVersion !== 1) throw new Error(`invalid pending manifest: ${directory}`);
723
+ const payload = fs.readFileSync(path.join(directory, "payload"));
724
+ if (payload.length !== manifest.rawBytes || sha256(payload) !== manifest.rawSha256) {
725
+ throw new Error(`pending payload checksum mismatch: ${directory}`);
726
+ }
727
+ const sessionCacheKey = `${manifest.session.provider}:${manifest.session.sessionTreeId}`;
728
+ let sessionId = remote.sessions.get(sessionCacheKey);
729
+ if (!sessionId) {
730
+ sessionId = await ensureRemoteSession(config, tenantId, manifest);
731
+ remote.sessions.set(sessionCacheKey, sessionId);
732
+ }
733
+ const root = path.dirname(path.dirname(directory));
734
+ await linkTaskBestEffort(config, tenantId, root, sessionId, manifest.session.taskId);
735
+ const streamCacheKey = `${sessionId}:${manifest.stream.kind}:${manifest.stream.subpath}:${manifest.stream.format}`;
736
+ let stream = remote.streams.get(streamCacheKey);
737
+ if (!stream) {
738
+ stream = await ensureRemoteStream(config, tenantId, sessionId, manifest.stream);
739
+ remote.streams.set(streamCacheKey, stream);
740
+ }
741
+ if (!Number.isSafeInteger(manifest.expectedSequence) || manifest.expectedSequence < 0) {
742
+ manifest.expectedSequence = stream.headSequence;
743
+ atomicWrite(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
744
+ }
745
+ const send = async () => apiRequest(config, tenantId, `/v1/streams/${stream.id}/batches/${encodeURIComponent(manifest.batchId)}`, {
746
+ method: "PUT",
747
+ headers: {
748
+ "content-type": manifest.stream.format === "ndjson" ? "application/x-ndjson" : "application/octet-stream",
749
+ "x-impel-expected-sequence": String(manifest.expectedSequence),
750
+ "x-impel-item-count": String(manifest.itemCount),
751
+ "x-impel-raw-sha256": manifest.rawSha256,
752
+ },
753
+ body: payload,
754
+ });
755
+ let compressed = compressZstd(payload);
756
+ const attemptUpload = async () => {
757
+ let result = compressed
758
+ ? await uploadDirect(config, tenantId, stream.id, manifest, payload, compressed)
759
+ : await send();
760
+ if (compressed && result.response.status === 409 && problemCode(result.body) === "direct_upload_unavailable") {
761
+ compressed = null;
762
+ result = await send();
763
+ }
764
+ return result;
765
+ };
766
+ let uploaded = await attemptUpload();
767
+ for (let recovery = 0; !uploaded.response.ok && recovery < 3; recovery += 1) {
768
+ const code = problemCode(uploaded.body);
769
+ if (![409, 410].includes(uploaded.response.status) || ![
770
+ "sequence_conflict",
771
+ "idempotency_conflict",
772
+ "upload_expired",
773
+ ].includes(code)) break;
774
+ if (compressed) await cancelDirectUpload(config, tenantId, stream.id, manifest.batchId);
775
+ stream = await ensureRemoteStream(config, tenantId, sessionId, manifest.stream);
776
+ remote.streams.set(streamCacheKey, stream);
777
+ manifest.expectedSequence = stream.headSequence;
778
+ atomicWrite(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
779
+ uploaded = await attemptUpload();
780
+ }
781
+ if (!uploaded.response.ok) {
782
+ const error = new Error(`batch upload failed with HTTP ${uploaded.response.status}: ${problemMessage(uploaded.body)}`);
783
+ error.httpStatus = uploaded.response.status;
784
+ error.problemCode = problemCode(uploaded.body);
785
+ throw error;
786
+ }
787
+ stream.headSequence = uploaded.body?.batch?.endSequence ?? (manifest.expectedSequence + manifest.itemCount);
788
+ fs.rmSync(directory, { recursive: true, force: true });
789
+ }
790
+
791
+ function pendingDirectories(root) {
792
+ const pending = path.join(root, "pending");
793
+ try {
794
+ return fs.readdirSync(pending, { withFileTypes: true })
795
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".tmp-"))
796
+ .map((entry) => path.join(pending, entry.name))
797
+ .sort();
798
+ } catch {
799
+ return [];
800
+ }
801
+ }
802
+
803
+ function directoryCount(root) {
804
+ try {
805
+ return fs.readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).length;
806
+ } catch {
807
+ return 0;
808
+ }
809
+ }
810
+
811
+ function recordBatchFailure(directory, error) {
812
+ const failurePath = path.join(directory, "failure.json");
813
+ const prior = readJson(failurePath, { attempts: 0 });
814
+ const failure = {
815
+ attempts: Number(prior.attempts || 0) + 1,
816
+ at: new Date().toISOString(),
817
+ message: redactSecretText(error?.message || error).slice(0, 1024),
818
+ ...(Number.isInteger(error?.httpStatus) ? { httpStatus: error.httpStatus } : {}),
819
+ ...(error?.problemCode ? { problemCode: error.problemCode } : {}),
820
+ };
821
+ atomicWrite(failurePath, `${JSON.stringify(failure, null, 2)}\n`);
822
+ return failure;
823
+ }
824
+
825
+ function batchFailureIsPermanent(error, failure) {
826
+ if (error?.permanentBatchFailure === true) return true;
827
+ if (/^invalid pending manifest:|^pending payload checksum mismatch:/u.test(error?.message || "")) return true;
828
+ if ([413, 422].includes(error?.httpStatus)) return true;
829
+ if ([
830
+ "batch_too_large",
831
+ "idempotency_conflict",
832
+ "invalid_batch_identity",
833
+ "invalid_checksum",
834
+ "invalid_payload",
835
+ "invalid_upload_manifest",
836
+ ].includes(error?.problemCode)) return failure.attempts >= MAX_BATCH_FAILURES;
837
+ return false;
838
+ }
839
+
840
+ function quarantineBatch(root, directory, failure) {
841
+ const failedRoot = path.join(root, "pending-failed");
842
+ privateDirectory(failedRoot);
843
+ const destination = path.join(failedRoot, path.basename(directory));
844
+ try {
845
+ fs.renameSync(directory, destination);
846
+ } catch (error) {
847
+ if (error?.code !== "ENOENT") throw error;
848
+ return;
849
+ }
850
+ atomicWrite(path.join(destination, "failure.json"), `${JSON.stringify({
851
+ ...failure,
852
+ quarantinedAt: new Date().toISOString(),
853
+ }, null, 2)}\n`);
854
+ const quarantined = fs.readdirSync(failedRoot, { withFileTypes: true })
855
+ .filter((entry) => entry.isDirectory())
856
+ .map((entry) => path.join(failedRoot, entry.name))
857
+ .sort();
858
+ for (const stale of quarantined.slice(0, Math.max(0, quarantined.length - MAX_QUARANTINED_BATCHES))) {
859
+ fs.rmSync(stale, { recursive: true, force: true });
860
+ }
861
+ }
862
+
863
+ async function flushRoot(root, tenantId, config, limit = MAX_FLUSH_BATCHES) {
864
+ const release = acquireLock(root, "flush");
865
+ if (!release) return { flushed: 0, pending: pendingDirectories(root).length, busy: true };
866
+ let flushed = 0;
867
+ const remote = { sessions: new Map(), streams: new Map() };
868
+ try {
869
+ while (flushed < limit) {
870
+ const directories = pendingDirectories(root).slice(0, limit - flushed);
871
+ if (directories.length === 0) break;
872
+ for (const directory of directories) {
873
+ try {
874
+ await uploadPending(config, tenantId, directory, remote);
875
+ flushed += 1;
876
+ } catch (error) {
877
+ const failure = recordBatchFailure(directory, error);
878
+ if (!batchFailureIsPermanent(error, failure)) throw error;
879
+ quarantineBatch(root, directory, failure);
880
+ }
881
+ }
882
+ }
883
+ const pendingLink = readJson(path.join(root, "task-link-error.json"));
884
+ if (pendingLink?.sessionId && pendingLink?.taskId) {
885
+ await linkTaskBestEffort(config, tenantId, root, pendingLink.sessionId, pendingLink.taskId);
886
+ }
887
+ clearError(root);
888
+ return { flushed, pending: pendingDirectories(root).length, busy: false };
889
+ } catch (error) {
890
+ writeError(root, error);
891
+ return { flushed, pending: pendingDirectories(root).length, busy: false, error };
892
+ } finally {
893
+ release();
894
+ }
895
+ }
896
+
897
+ export async function flushSessionOutbox({ tenantId, provider, sessionKey, config = loadConfig() }) {
898
+ return flushRoot(sessionDirectory(tenantId, provider, sessionKey), tenantId, config);
899
+ }
900
+
901
+ async function flushOneOtherOutbox({ tenantId, provider, currentRoot, config }) {
902
+ const providerRoot = path.dirname(currentRoot);
903
+ let candidates;
904
+ try {
905
+ candidates = fs.readdirSync(providerRoot, { withFileTypes: true })
906
+ .filter((entry) => entry.isDirectory())
907
+ .map((entry) => path.join(providerRoot, entry.name))
908
+ .filter((root) => root !== currentRoot && (
909
+ pendingDirectories(root).length > 0 || fs.existsSync(path.join(root, "task-link-error.json"))
910
+ ))
911
+ .sort();
912
+ } catch {
913
+ return { flushed: 0, pending: 0 };
914
+ }
915
+ if (candidates.length === 0) return { flushed: 0, pending: 0 };
916
+ return flushRoot(candidates[0], tenantId, config, Math.min(4, MAX_FLUSH_BATCHES));
917
+ }
918
+
919
+ export async function flushCollectedSession({ tenantId, provider, sessionKey, config = loadConfig() }) {
920
+ const root = sessionDirectory(tenantId, provider, sessionKey);
921
+ const current = await flushSessionOutbox({ tenantId, provider, sessionKey, config });
922
+ if (!current.error && !current.busy) {
923
+ const other = await flushOneOtherOutbox({ tenantId, provider, currentRoot: root, config });
924
+ return {
925
+ ...current,
926
+ flushed: current.flushed + other.flushed,
927
+ recoveredOtherSessionBatches: other.flushed,
928
+ };
929
+ }
930
+ return current;
931
+ }
932
+
933
+ export async function collectSessionHook({ provider, surface, tenantId, input, config = loadConfig(), flush = true }) {
934
+ if (!tenantId) throw new Error("The hook has no Impel tenant");
935
+ if (!["claude_code", "codex"].includes(provider)) throw new Error("Unsupported session provider");
936
+ if (!SAFE_ID_RE.test(surface)) throw new Error("Invalid session surface");
937
+ const sessionKey = String(input?.session_id || "").trim();
938
+ if (!sessionKey || sessionKey.length > 512 || /[\r\n\0]/u.test(sessionKey)) throw new Error("Hook input has no valid session_id");
939
+ const taskId = String(process.env.IMPEL_TASK_ID || "").trim();
940
+ if (taskId && !TASK_ID_RE.test(taskId)) throw new Error("IMPEL_TASK_ID is invalid");
941
+ const sessionMeta = {
942
+ provider,
943
+ surface,
944
+ sessionTreeId: sessionKey,
945
+ providerThreadId: sessionKey,
946
+ cwd: typeof input.cwd === "string" ? input.cwd : process.cwd(),
947
+ ...(taskId ? { taskId } : {}),
948
+ };
949
+ const root = sessionDirectory(tenantId, provider, sessionKey);
950
+ privateDirectory(root);
951
+ captureLedger(root, sessionMeta, input);
952
+ const releaseCapture = acquireLock(root, "capture", 30_000);
953
+ if (releaseCapture) {
954
+ try {
955
+ captureTranscript(root, sessionMeta, input);
956
+ } finally {
957
+ releaseCapture();
958
+ }
959
+ }
960
+ const limits = enforceOutboxLimits(root);
961
+ const selectedTenant = process.env.IMPEL_SESSIONS_DEV_ORG_ID || config?.tenantId === tenantId;
962
+ if (flush && config && selectedTenant) return flushCollectedSession({ tenantId, provider, sessionKey, config });
963
+ return {
964
+ flushed: 0,
965
+ pending: pendingDirectories(root).length,
966
+ queued: true,
967
+ dropped: limits.dropped,
968
+ ...(!selectedTenant ? { deferredForTenantSelection: true } : {}),
969
+ };
970
+ }
971
+
972
+ export async function readHookInput(stream = process.stdin) {
973
+ const chunks = [];
974
+ let bytes = 0;
975
+ for await (const chunk of stream) {
976
+ bytes += chunk.length;
977
+ if (bytes > MAX_HOOK_INPUT_BYTES) throw new Error("Hook input exceeds the 2 MiB safety limit");
978
+ chunks.push(chunk);
979
+ }
980
+ const raw = Buffer.concat(chunks).toString("utf8");
981
+ const value = JSON.parse(raw);
982
+ if (!value || Array.isArray(value) || typeof value !== "object") throw new Error("Hook input must be a JSON object");
983
+ return value;
984
+ }
985
+
986
+ export function sessionOutboxStatus({ tenantId, provider, sessionKey }) {
987
+ const root = sessionDirectory(tenantId, provider, sessionKey);
988
+ return {
989
+ root,
990
+ pending: pendingDirectories(root).length,
991
+ quarantined: directoryCount(path.join(root, "pending-failed")),
992
+ dropped: readJson(path.join(root, "outbox-drops.json")),
993
+ taskLinkError: readJson(path.join(root, "task-link-error.json")),
994
+ lastError: readJson(path.join(root, "last-error.json")),
995
+ };
996
+ }