@usepowerplant/cli 0.4.20 → 0.4.22

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,747 @@
1
+ /**
2
+ * Powerplant cloud session capture: the Stop hook for this repository's
3
+ * Claude cloud sessions. See docs/cloud-session-rules.md.
4
+ *
5
+ * What it sends: after each turn, this session's transcript, redacted here
6
+ * first with Powerplant's shared redaction and then gzipped, to Powerplant's
7
+ * API only (POST /api/ingest/cloud-sessions/raw). Two headers name the cloud
8
+ * session id and this repository's owner/name. It sends no environment
9
+ * values and no other files.
10
+ *
11
+ * Where it runs: only in a Claude cloud session (CLAUDE_CODE_REMOTE=true).
12
+ * On a laptop it exits at once, and the Powerplant sync service captures the
13
+ * session instead.
14
+ *
15
+ * Credential: the member's cloud capture token. Anthropic's agent proxy adds
16
+ * it outside the VM when it is an API credential on the cloud environment.
17
+ * Otherwise the hook reads POWERPLANT_CAPTURE_TOKEN. It never puts the token
18
+ * on a command line.
19
+ *
20
+ * It never blocks the session. It stops for the session when Powerplant
21
+ * refuses the upload or three turns in a row fail (a busy answer is not a
22
+ * failure), and writes each result to powerplant-cloud-capture/ in the
23
+ * system temp directory.
24
+ *
25
+ * Generated from apps/api/src/cloudCaptureHook.ts by
26
+ * apps/api/scripts/build-cloud-capture-hook.mts. Edit the source, not this file.
27
+ */
28
+
29
+ // src/cloudCaptureHook.ts
30
+ import { spawn, spawnSync } from "node:child_process";
31
+ import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
32
+ import os from "node:os";
33
+ import path from "node:path";
34
+ import { gzipSync } from "node:zlib";
35
+
36
+ // ../../packages/core/src/encodedImages.ts
37
+ var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
38
+ function isEncodedImage(data, mediaType) {
39
+ return typeof mediaType === "string" && encodedImageMediaType(data) === mediaType;
40
+ }
41
+ var RASTER_TYPES = [
42
+ ["image/png", isPng],
43
+ ["image/jpeg", isJpeg],
44
+ ["image/gif", isGif],
45
+ ["image/webp", isWebp]
46
+ ];
47
+ function encodedImageMediaType(data) {
48
+ if (data.length < 16 || data.length % 4 !== 0) return null;
49
+ const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
50
+ const body = data.slice(0, data.length - padding);
51
+ if (/[^A-Za-z0-9+/]/.test(body)) return null;
52
+ const last = BASE64_ALPHABET.indexOf(body.at(-1));
53
+ if (padding === 2 && (last & 15) !== 0 || padding === 1 && (last & 3) !== 0) return null;
54
+ const bytes = atob(data);
55
+ return RASTER_TYPES.find(([, valid]) => valid(bytes))?.[0] ?? null;
56
+ }
57
+ function u16(data, offset) {
58
+ return data.charCodeAt(offset) << 8 | data.charCodeAt(offset + 1);
59
+ }
60
+ function u32(data, offset) {
61
+ return u16(data, offset) * 65536 + u16(data, offset + 2) >>> 0;
62
+ }
63
+ function little32(data, offset) {
64
+ return (data.charCodeAt(offset) | data.charCodeAt(offset + 1) << 8 | data.charCodeAt(offset + 2) << 16 | data.charCodeAt(offset + 3) << 24) >>> 0;
65
+ }
66
+ var CRC_TABLE = Array.from({ length: 256 }, (_, value) => {
67
+ for (let bit = 0; bit < 8; bit++) value = value & 1 ? 3988292384 ^ value >>> 1 : value >>> 1;
68
+ return value >>> 0;
69
+ });
70
+ function crc32(data, start, end) {
71
+ let crc = 4294967295;
72
+ for (let i = start; i < end; i++)
73
+ crc = CRC_TABLE[(crc ^ data.charCodeAt(i)) & 255] ^ crc >>> 8;
74
+ return (crc ^ 4294967295) >>> 0;
75
+ }
76
+ function isPng(data) {
77
+ if (!data.startsWith("\x89PNG\r\n\x1a\n")) return false;
78
+ let offset = 8;
79
+ let imageData = false;
80
+ let dataEnded = false;
81
+ let palette = false;
82
+ let indexed = false;
83
+ while (offset + 12 <= data.length) {
84
+ const size = u32(data, offset);
85
+ const type = data.slice(offset + 4, offset + 8);
86
+ const end = offset + 8 + size;
87
+ if (size > 2147483647 || end + 4 > data.length || !/^[A-Za-z]{2}[A-Z][A-Za-z]$/.test(type))
88
+ return false;
89
+ if (u32(data, end) !== crc32(data, offset + 4, end)) return false;
90
+ if (offset === 8) {
91
+ if (type !== "IHDR" || size !== 13 || !u32(data, offset + 8) || !u32(data, offset + 12))
92
+ return false;
93
+ const depth = data.charCodeAt(offset + 16);
94
+ const color = data.charCodeAt(offset + 17);
95
+ const validDepth = color === 0 ? [1, 2, 4, 8, 16] : color === 3 ? [1, 2, 4, 8] : [8, 16];
96
+ if (![0, 2, 3, 4, 6].includes(color) || !validDepth.includes(depth)) return false;
97
+ if (data.charCodeAt(offset + 18) !== 0 || data.charCodeAt(offset + 19) !== 0 || data.charCodeAt(offset + 20) > 1)
98
+ return false;
99
+ indexed = color === 3;
100
+ } else if (type === "IHDR") return false;
101
+ else if (type === "PLTE") {
102
+ if (imageData || palette || size === 0 || size > 768 || size % 3 !== 0) return false;
103
+ palette = true;
104
+ } else if (type === "IDAT") {
105
+ if (dataEnded || indexed && !palette) return false;
106
+ imageData = imageData || size > 0;
107
+ } else if (type === "IEND") return imageData && size === 0 && end + 4 === data.length;
108
+ else if (type[0] === type[0].toUpperCase()) return false;
109
+ if (imageData && type !== "IDAT") dataEnded = true;
110
+ offset = end + 4;
111
+ }
112
+ return false;
113
+ }
114
+ function isJpeg(data) {
115
+ if (!data.startsWith("\xFF\xD8")) return false;
116
+ let offset = 2;
117
+ let frame = false;
118
+ let scan = false;
119
+ while (offset < data.length) {
120
+ if (data.charCodeAt(offset++) !== 255) return false;
121
+ while (data.charCodeAt(offset) === 255) offset++;
122
+ const marker = data.charCodeAt(offset++);
123
+ if (marker === 217) return frame && scan && offset === data.length;
124
+ if (marker === 0 || marker === 216 || marker >= 208 && marker <= 215) return false;
125
+ if (marker === 1) continue;
126
+ if (offset + 2 > data.length) return false;
127
+ const size = u16(data, offset);
128
+ if (size < 2 || offset + size > data.length) return false;
129
+ if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker)) {
130
+ if (size < 11 || size !== 8 + 3 * data.charCodeAt(offset + 7) || !u16(data, offset + 3) || !u16(data, offset + 5))
131
+ return false;
132
+ frame = true;
133
+ }
134
+ if (marker !== 218) {
135
+ offset += size;
136
+ continue;
137
+ }
138
+ if (!frame || size < 8 || size !== 6 + 2 * data.charCodeAt(offset + 2)) return false;
139
+ offset += size;
140
+ const start = offset;
141
+ for (; ; ) {
142
+ const next = data.indexOf("\xFF", offset);
143
+ if (next < 0 || next + 1 >= data.length) return false;
144
+ const following = data.charCodeAt(next + 1);
145
+ if (following === 0 || following >= 208 && following <= 215) offset = next + 2;
146
+ else if (following === 255) offset = next + 1;
147
+ else {
148
+ offset = next;
149
+ break;
150
+ }
151
+ }
152
+ if (offset <= start) return false;
153
+ scan = true;
154
+ }
155
+ return false;
156
+ }
157
+ function skipGifBlocks(data, offset) {
158
+ while (offset < data.length) {
159
+ const size = data.charCodeAt(offset++);
160
+ if (size === 0) return offset;
161
+ offset += size;
162
+ }
163
+ return -1;
164
+ }
165
+ function isGif(data) {
166
+ if (!data.startsWith("GIF87a") && !data.startsWith("GIF89a") || data.length < 13) return false;
167
+ if (!(data.charCodeAt(6) | data.charCodeAt(7)) || !(data.charCodeAt(8) | data.charCodeAt(9)))
168
+ return false;
169
+ const packed = data.charCodeAt(10);
170
+ let offset = 13 + (packed & 128 ? 3 * (2 << (packed & 7)) : 0);
171
+ let image = false;
172
+ while (offset < data.length) {
173
+ const marker = data.charCodeAt(offset++);
174
+ if (marker === 59) return image && offset === data.length;
175
+ if (marker === 33) offset = skipGifBlocks(data, offset + 1);
176
+ else if (marker === 44) {
177
+ if (offset + 9 > data.length || !(data.charCodeAt(offset + 4) | data.charCodeAt(offset + 5)) || !(data.charCodeAt(offset + 6) | data.charCodeAt(offset + 7)))
178
+ return false;
179
+ const local = data.charCodeAt(offset + 8);
180
+ offset += 9 + (local & 128 ? 3 * (2 << (local & 7)) : 0);
181
+ const codeSize = data.charCodeAt(offset++);
182
+ if (codeSize < 2 || codeSize > 8 || offset >= data.length || data.charCodeAt(offset) === 0)
183
+ return false;
184
+ offset = skipGifBlocks(data, offset);
185
+ image = true;
186
+ } else return false;
187
+ if (offset < 0) return false;
188
+ }
189
+ return false;
190
+ }
191
+ function isWebp(data) {
192
+ return data.startsWith("RIFF") && data.slice(8, 12) === "WEBP" && little32(data, 4) + 8 === data.length && webpChunks(data, 12, data.length, true);
193
+ }
194
+ function webpChunks(data, start, end, allowFrames) {
195
+ let image = false;
196
+ let offset = start;
197
+ while (offset + 8 <= end) {
198
+ const type = data.slice(offset, offset + 4);
199
+ const size = little32(data, offset + 4);
200
+ const body = offset + 8;
201
+ const next = body + size + size % 2;
202
+ if (next > end) return false;
203
+ if (type === "VP8 ") {
204
+ if (size < 11 || data.charCodeAt(body) & 1 || data.slice(body + 3, body + 6) !== "\x9D\x01*")
205
+ return false;
206
+ if (!((data.charCodeAt(body + 6) | data.charCodeAt(body + 7) << 8) & 16383) || !((data.charCodeAt(body + 8) | data.charCodeAt(body + 9) << 8) & 16383))
207
+ return false;
208
+ image = true;
209
+ } else if (type === "VP8L") {
210
+ if (size < 6 || data.charCodeAt(body) !== 47 || data.charCodeAt(body + 4) & 224)
211
+ return false;
212
+ image = true;
213
+ } else if (type === "VP8X") {
214
+ if (size !== 10 || !allowFrames) return false;
215
+ } else if (type === "ANMF") {
216
+ if (!allowFrames || size < 16 || !webpChunks(data, body + 16, body + size, false))
217
+ return false;
218
+ image = true;
219
+ }
220
+ offset = next;
221
+ }
222
+ return image && offset === end;
223
+ }
224
+
225
+ // ../../packages/core/src/transcriptImages.ts
226
+ var IMAGE_DATA_URI_RE = /^data:(image\/[\w.+-]+);base64,([A-Za-z0-9+/=_-]*)$/;
227
+ function isImageDataUri(value) {
228
+ if (!value.startsWith("data:image/")) return false;
229
+ const match = IMAGE_DATA_URI_RE.exec(value);
230
+ return match !== null && isEncodedImage(match[2], match[1]);
231
+ }
232
+ function isImageBase64Payload(record, key) {
233
+ const data = record[key];
234
+ if (typeof data !== "string") return false;
235
+ if (key === "data" && record.type === "base64") return isEncodedImage(data, record.media_type);
236
+ if (key === "base64") return isEncodedImage(data, record.type);
237
+ return false;
238
+ }
239
+
240
+ // ../../packages/core/src/powerplantTokenPattern.ts
241
+ var POWERPLANT_TOKEN_PATTERN = /\bpp[mc]_[A-Za-z0-9_-]{43,}/g;
242
+
243
+ // ../../packages/core/src/redact.ts
244
+ var REDACTED = "[REDACTED]";
245
+ var SECRET_KEYS = /* @__PURE__ */ new Set(["secrets", "env_vars", "secrets_with_domains"]);
246
+ var SECRET_VALUE_KEYS = /* @__PURE__ */ new Set([
247
+ "password",
248
+ "passwd",
249
+ "secret",
250
+ "token",
251
+ "accesstoken",
252
+ "refreshtoken",
253
+ "idtoken",
254
+ "apikey",
255
+ "authorization",
256
+ "privatekey",
257
+ "clientsecret",
258
+ "credential",
259
+ "credentials",
260
+ "servicerolekey"
261
+ ]);
262
+ function normalizeSecretKey(key) {
263
+ return key.toLowerCase().replace(/[_-]/g, "");
264
+ }
265
+ var TOKEN_PATTERNS = [
266
+ POWERPLANT_TOKEN_PATTERN,
267
+ // JWTs, incl. Supabase anon/service-role keys (three base64url segments).
268
+ /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}/g,
269
+ // OpenAI/Anthropic-style keys (sk-…, sk-ant-…, sk-proj-…).
270
+ /\bsk-[A-Za-z0-9_-]{20,}/g,
271
+ // GitHub tokens: classic (ghp_/gho_/ghu_/ghs_/ghr_) and fine-grained.
272
+ /\bgh[pousr]_[A-Za-z0-9]{20,}/g,
273
+ /\bgithub_pat_[A-Za-z0-9_]{22,}/g,
274
+ // AWS access key ids.
275
+ /\b(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}\b/g,
276
+ // Slack tokens.
277
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
278
+ // Google API keys.
279
+ /\bAIza[0-9A-Za-z_-]{35}/g,
280
+ // Stripe secret/restricted keys and webhook signing secrets.
281
+ /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g,
282
+ /\bwhsec_[A-Za-z0-9]{24,}/g,
283
+ // npm automation tokens.
284
+ /\bnpm_[A-Za-z0-9]{36}/g,
285
+ // 1Password service-account tokens (OP_TOKEN values).
286
+ /\bops_[A-Za-z0-9_-]{16,}/g,
287
+ // Supabase personal access tokens.
288
+ /\bsbp_[A-Za-z0-9]{40}/g
289
+ ];
290
+ var CONTEXT_PATTERNS = [
291
+ // PEM private-key blocks, complete header-to-footer.
292
+ {
293
+ pattern: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g,
294
+ replacement: REDACTED
295
+ },
296
+ // Torn PEM body: a transcript cut mid-key has the header and base64 body
297
+ // but no END marker. Consume the header plus every following run of
298
+ // base64-looking material (16+ chars, optionally separated by whitespace
299
+ // or JSON \n escapes), allow one short trailing run (a key's final line
300
+ // is usually short), and stop at ordinary text. Neither anchored to
301
+ // end-of-input (that left key-body-then-prose unredacted) nor greedy
302
+ // over prose (end-of-input as an END alternate wiped whole streams after
303
+ // one header mention).
304
+ // The optional short tail is a base64-PADDED line (ends in =): that's the
305
+ // real shape of a key's short final line, and the padding requirement is
306
+ // what keeps an ordinary prose word after a torn body from being eaten.
307
+ {
308
+ pattern: (
309
+ // Header/base64 alphabets are fixed; greedy consumption prevents secret
310
+ // fragments surviving. Existing redaction tests cover torn inputs.
311
+ // eslint-disable-next-line security/detect-unsafe-regex
312
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----(?:(?:\s|\\n)*[A-Za-z0-9+/=]{16,})+(?:(?:\s|\\n)+[A-Za-z0-9+/]{0,13}={1,2})?/g
313
+ ),
314
+ replacement: REDACTED
315
+ },
316
+ // A cut so early only a short fragment follows the header: fires only at
317
+ // end of input, which is the per-line torn-JSONL shape (each line is its
318
+ // own input) and the tail of a stream cut mid-write. Prose mentions
319
+ // mid-stream never reach this rule.
320
+ {
321
+ pattern: (
322
+ // End anchoring bounds the torn short-fragment fallback to one input tail.
323
+ // eslint-disable-next-line security/detect-unsafe-regex
324
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----(?:\s|\\n)*(?:[A-Za-z0-9+/=]{1,15})?(?:\s|\\n)*$/g
325
+ ),
326
+ replacement: REDACTED
327
+ },
328
+ // Passwords inside connection URLs: scheme://user:password@host. The
329
+ // scheme quantifier is bounded (real schemes are short) — unbounded, it
330
+ // backtracked O(n) at every offset of a long unbroken alphanumeric run,
331
+ // making redaction quadratic on big base64-ish payloads (~4s at 64KB).
332
+ {
333
+ pattern: /([a-z][a-z0-9+.-]{0,31}:\/\/[^\s:@/"'\\]+:)[^\s@/"'\\]+@/gi,
334
+ replacement: `$1${REDACTED}@`
335
+ },
336
+ // Bearer credentials wherever they appear (headers, curl commands).
337
+ { pattern: /\b(bearer\s+)[A-Za-z0-9._~+/=-]{16,}/gi, replacement: `$1${REDACTED}` },
338
+ // Basic credentials only in an Authorization context — bare "basic <word>"
339
+ // is common prose, "Authorization: Basic <base64>" is not.
340
+ {
341
+ pattern: /(authorization\\?["']?\s*[:=]\s*\\?["']?basic\s+)[A-Za-z0-9+/=]{8,}/gi,
342
+ replacement: `$1${REDACTED}`
343
+ },
344
+ // Env-style assignments whose NAME says secret (FOO_TOKEN=…, DB_PASSWORD: …).
345
+ // Value must be 8+ chars and not purely numeric, so flag words and counter
346
+ // settings (MAX_TOKENS=4096) survive.
347
+ {
348
+ pattern: /\b([A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|ACCESS_KEY|PRIVATE_KEY|SERVICE_KEY|SERVICE_ROLE_KEY|SIGNING_KEY|ENCRYPTION_KEY|MASTER_KEY|CREDENTIALS?)[A-Z0-9_]*\s*[=:]\s*\\?["']?)(?!\d+["'\s\\,;]|\d+$)[^\s\\"',;]{8,}/g,
349
+ replacement: `$1${REDACTED}`
350
+ }
351
+ ];
352
+ var SECRET_NAME_WORD = /secret|passw(?:or)?d/;
353
+ var SECRET_NAME_SUFFIX = /(?:token|apikey|accesskey|privatekey|credentials?|servicekey|signingkey|encryptionkey|masterkey)$/;
354
+ function isSecretName(name) {
355
+ const key = normalizeSecretKey(name);
356
+ return SECRET_VALUE_KEYS.has(key) || SECRET_NAME_WORD.test(key) || SECRET_NAME_SUFFIX.test(key);
357
+ }
358
+ function isSecretValue(value) {
359
+ return !/^\d+$/.test(value) && !/^\$[{A-Za-z_]/.test(value);
360
+ }
361
+ var QUOTED_SECRET_VALUE = /\b([A-Za-z_][\w.-]{0,63})(\\?["']?\s*[=:]\s*)(\\?["'])([^"'\\\s]{8,})\3/g;
362
+ var CONFIG_LINE_SECRET_VALUE = (
363
+ // Anchored to line starts, with bounded prefix and name lengths, so each
364
+ // line is scanned once. Indentation after a line number sits inside the
365
+ // line-number group: two adjacent whitespace runs made a long run of
366
+ // spaces quadratic.
367
+ // eslint-disable-next-line security/detect-unsafe-regex
368
+ /^([+-]?(?:(?:[^\s:]{1,256}:)?\d+[:-])?[ \t]*(?:\d+(?:\t|\u2192)[ \t]*)?(?:(?:export|-)[ \t]+)?)([A-Za-z_][\w.-]{0,63})([ \t]*[=:][ \t]*)([^\s"'\\,;()[\]{}<>]{8,})(?=[ \t]*(?:#.*)?\r?$)/gm
369
+ );
370
+ function jsonTokens(text) {
371
+ const tokens = [];
372
+ for (const { index } of text.matchAll(/["\r\n]/g)) {
373
+ if (text[index] !== '"') {
374
+ tokens.push({ index, level: -1 });
375
+ continue;
376
+ }
377
+ let backslashes = 0;
378
+ while (text[index - backslashes - 1] === "\\") backslashes += 1;
379
+ let level = 0;
380
+ for (let run = backslashes + 1; run % 2 === 0; run /= 2) level += 1;
381
+ tokens.push({ index, level });
382
+ }
383
+ return tokens;
384
+ }
385
+ function stringEnds(tokens) {
386
+ const ends = [];
387
+ const pending = [];
388
+ for (let i = tokens.length - 1; i >= 0; i -= 1) {
389
+ const { level } = tokens[i];
390
+ while (pending.length > 0 && tokens[pending.at(-1)].level > level) pending.pop();
391
+ ends[i] = pending.at(-1) ?? -1;
392
+ pending.push(i);
393
+ }
394
+ return ends;
395
+ }
396
+ function stringTextEnd(text, end) {
397
+ if (end === void 0) return text.length;
398
+ return end.level < 0 ? end.index : end.index - (2 ** end.level - 1);
399
+ }
400
+ function skipJsonSpace(text, from, level) {
401
+ const escapeLength = level === 0 ? 0 : 2 ** (level - 1);
402
+ let at = from;
403
+ for (; ; ) {
404
+ const char = text[at];
405
+ if (char === " " || level === 0 && (char === "\x09" || char === "\r" || char === "\n")) at += 1;
406
+ else if (char === "\\" && level > 0 && text.startsWith("\\".repeat(escapeLength), at) && /^[nrt]$/.test(text[at + escapeLength] ?? "")) {
407
+ at += escapeLength + 1;
408
+ } else return at;
409
+ }
410
+ }
411
+ function decodeJsonKey(raw, level) {
412
+ if (!raw.includes("\\")) return raw;
413
+ let key = raw;
414
+ for (let round = 0; round <= level; round += 1) {
415
+ try {
416
+ const decoded = JSON.parse(`"${key}"`);
417
+ if (typeof decoded !== "string") return key;
418
+ key = decoded;
419
+ } catch {
420
+ return key;
421
+ }
422
+ }
423
+ return key;
424
+ }
425
+ function redactJsonSecretValues(text) {
426
+ if (!text.includes('"')) return text;
427
+ const tokens = jsonTokens(text);
428
+ const ends = stringEnds(tokens);
429
+ let out = "";
430
+ let copied = 0;
431
+ for (const [i, { index, level }] of tokens.entries()) {
432
+ if (level < 0 || index < copied) continue;
433
+ const keyEnd = ends[i] ?? -1;
434
+ const keyClose = tokens[keyEnd];
435
+ if (keyClose?.level !== level) continue;
436
+ const colon = skipJsonSpace(text, keyClose.index + 1, level);
437
+ if (text[colon] !== ":") continue;
438
+ const valueOpen = skipJsonSpace(text, colon + 1, level);
439
+ let valueQuote = keyEnd + 1;
440
+ while (tokens[valueQuote]?.level === -1 && (tokens[valueQuote]?.index ?? Infinity) < valueOpen) valueQuote += 1;
441
+ const quote = tokens[valueQuote];
442
+ if (quote?.level !== level || quote.index !== valueOpen + 2 ** level - 1) continue;
443
+ if (!isSecretName(decodeJsonKey(text.slice(index + 1, stringTextEnd(text, keyClose)), level))) continue;
444
+ const valueStart = quote.index + 1;
445
+ const valueEnd = stringTextEnd(text, tokens[ends[valueQuote] ?? -1]);
446
+ if (valueEnd <= valueStart) continue;
447
+ out += text.slice(copied, valueStart) + REDACTED;
448
+ copied = valueEnd;
449
+ }
450
+ return out + text.slice(copied);
451
+ }
452
+ function redactSecretNamedValues(text) {
453
+ return redactJsonSecretValues(text).replace(
454
+ QUOTED_SECRET_VALUE,
455
+ (match, name, separator, quote, value) => isSecretName(name) && isSecretValue(value) ? `${name}${separator}${quote}${REDACTED}${quote}` : match
456
+ ).replace(
457
+ CONFIG_LINE_SECRET_VALUE,
458
+ (match, prefix, name, separator, value) => isSecretName(name) && isSecretValue(value) && /[\d/+=!@#$%^&*~?]/.test(value) ? `${prefix}${name}${separator}${REDACTED}` : match
459
+ );
460
+ }
461
+ function redactText(text) {
462
+ let out = text;
463
+ for (const { pattern, replacement } of CONTEXT_PATTERNS) {
464
+ out = out.replace(pattern, replacement);
465
+ }
466
+ out = redactSecretNamedValues(out);
467
+ for (const pattern of TOKEN_PATTERNS) {
468
+ out = out.replace(pattern, REDACTED);
469
+ }
470
+ return out;
471
+ }
472
+ function redactSecrets(text) {
473
+ let parsed;
474
+ try {
475
+ parsed = JSON.parse(text);
476
+ } catch {
477
+ return redactText(text);
478
+ }
479
+ const strip = (value) => {
480
+ if (typeof value === "string") return isImageDataUri(value) ? value : redactText(value);
481
+ if (Array.isArray(value)) return value.map(strip);
482
+ if (value && typeof value === "object") {
483
+ const record = value;
484
+ for (const key of Object.keys(record)) {
485
+ if (SECRET_KEYS.has(key)) delete record[key];
486
+ else if (isSecretName(key) && typeof record[key] === "string") {
487
+ record[key] = REDACTED;
488
+ } else if (isImageBase64Payload(record, key)) {
489
+ } else record[key] = strip(record[key]);
490
+ }
491
+ }
492
+ return value;
493
+ };
494
+ return JSON.stringify(strip(parsed));
495
+ }
496
+ function redactJsonl(text) {
497
+ return text.split("\n").map((line) => line.trim() === "" ? line : redactSecrets(line)).join("\n");
498
+ }
499
+ var utf8Encoder = new TextEncoder();
500
+ var utf8Decoder = new TextDecoder();
501
+
502
+ // ../../packages/core/src/sessionViews.ts
503
+ var CLOUD_SESSION_ID = /^session_[A-Za-z0-9]+$/;
504
+ function claudeCloudSessionUrl(cloudSessionId) {
505
+ return CLOUD_SESSION_ID.test(cloudSessionId) ? `https://claude.ai/code/${cloudSessionId}` : null;
506
+ }
507
+ var TEAM_ACTIVITY_FRESH_WINDOW_MS = 48 * 36e5;
508
+ var MACHINE_ENROLL_CALLBACK_PORT = 41999;
509
+ var MACHINE_ENROLL_CALLBACK_PATH = "/callback";
510
+ var MACHINE_ENROLL_CALLBACK_URL = `http://127.0.0.1:${MACHINE_ENROLL_CALLBACK_PORT}${MACHINE_ENROLL_CALLBACK_PATH}`;
511
+
512
+ // ../../packages/core/src/cloudCaptureToken.ts
513
+ var CLOUD_CAPTURE_TOKEN = /ppu_[A-Za-z0-9_-]{43,}/g;
514
+ function redactCloudCaptureTokens(text) {
515
+ return text.replace(CLOUD_CAPTURE_TOKEN, "[REDACTED]");
516
+ }
517
+
518
+ // src/cloudTranscript.ts
519
+ var MAX_CLOUD_TRANSCRIPT_BYTES = 64 * 1024 * 1024;
520
+ var CLOUD_SESSION_HEADER = "x-powerplant-cloud-session";
521
+ var CLOUD_REPO_HEADER = "x-powerplant-repo";
522
+ var CLOUD_REPO_PATTERN = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
523
+
524
+ // src/cloudCaptureHook.ts
525
+ var DEFAULT_API_URL = "https://api.usepowerplant.com";
526
+ var MAX_GZIPPED_BYTES = MAX_CLOUD_TRANSCRIPT_BYTES / 2;
527
+ var TRANSCRIPT_QUIET_MS = 2e3;
528
+ var TRANSCRIPT_WAIT_MS = 15e3;
529
+ var MAX_FAILED_TURNS = 3;
530
+ var HOOK_DEADLINE_MS = 18e4;
531
+ var CURL_MAX_TIME_SECONDS = 120;
532
+ var TOKEN_PATTERN = /^[A-Za-z0-9_-]+$/;
533
+ function cloudSessionIdFromEnv(value) {
534
+ const id = (value ?? "").replace(/^cse_/, "session_");
535
+ return claudeCloudSessionUrl(id) === null ? null : id;
536
+ }
537
+ function repoFromRemoteUrl(remoteUrl) {
538
+ const segments = remoteUrl.trim().replace(/\/+$/, "").replace(/\.git$/, "").split(/[/:]/);
539
+ const repo = segments.slice(-2).join("/");
540
+ return segments.length >= 2 && CLOUD_REPO_PATTERN.test(repo) ? repo : null;
541
+ }
542
+ var upstreamError = (body) => {
543
+ try {
544
+ const parsed = JSON.parse(body);
545
+ if (parsed && typeof parsed === "object" && "error" in parsed && typeof parsed.error === "string") {
546
+ return parsed.error;
547
+ }
548
+ } catch {
549
+ }
550
+ return "no error message";
551
+ };
552
+ function uploadOutcome(httpStatus, body) {
553
+ if (httpStatus === 200) {
554
+ let data;
555
+ try {
556
+ data = JSON.parse(body).data;
557
+ } catch {
558
+ data = void 0;
559
+ }
560
+ const stored = data && typeof data === "object" && "stored" in data ? data.stored : void 0;
561
+ if (stored === true) return { result: "stored", next: "continue", message: "Transcript stored." };
562
+ const reason = data && typeof data === "object" && "reason" in data ? String(data.reason) : "unknown";
563
+ if (reason === "older") {
564
+ return { result: "not-stored", next: "continue", message: "Powerplant already has a newer copy." };
565
+ }
566
+ return {
567
+ result: "not-stored",
568
+ next: "stop",
569
+ message: reason === "outside-scope" ? "Powerplant does not capture this repository with this token." : reason === "deleted" ? "This session was deleted in Powerplant." : `Powerplant did not store the transcript (${reason}).`
570
+ };
571
+ }
572
+ if (httpStatus === 401) {
573
+ return {
574
+ result: "failed",
575
+ next: "stop",
576
+ message: "Powerplant got no valid capture token. Add the token to this cloud environment (apps/api/scripts/cloud-capture-token.mts)."
577
+ };
578
+ }
579
+ const message = `HTTP ${httpStatus}: ${upstreamError(body)}`;
580
+ if (httpStatus === 429) return { result: "busy", next: "continue", message };
581
+ return { result: "failed", next: httpStatus >= 500 ? "continue" : "stop", message };
582
+ }
583
+ function curlConfig(input) {
584
+ const quoted = (value) => `"${value.replace(/[\\"]/g, (char) => `\\${char}`).replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`;
585
+ const lines = [
586
+ `url = ${quoted(input.url)}`,
587
+ `header = "Content-Type: application/gzip"`,
588
+ `header = ${quoted(`${CLOUD_SESSION_HEADER}: ${input.cloudSessionId}`)}`,
589
+ `header = ${quoted(`${CLOUD_REPO_HEADER}: ${input.repo}`)}`,
590
+ `data-binary = ${quoted(`@${input.bodyPath}`)}`,
591
+ `output = ${quoted(input.responsePath)}`,
592
+ `write-out = "%{http_code}"`,
593
+ `max-time = ${CURL_MAX_TIME_SECONDS}`,
594
+ "silent",
595
+ "show-error"
596
+ ];
597
+ if (input.token) lines.push(`header = ${quoted(`Authorization: Bearer ${input.token}`)}`);
598
+ return `${lines.join("\n")}
599
+ `;
600
+ }
601
+ function readHookInput(env, stdin) {
602
+ let transcriptPath;
603
+ try {
604
+ transcriptPath = JSON.parse(stdin).transcript_path;
605
+ } catch {
606
+ return "The hook input is not JSON.";
607
+ }
608
+ if (typeof transcriptPath !== "string" || transcriptPath === "") return "The hook input has no transcript_path.";
609
+ const cloudSessionId = cloudSessionIdFromEnv(env.CLAUDE_CODE_REMOTE_SESSION_ID);
610
+ if (!cloudSessionId) return "CLAUDE_CODE_REMOTE_SESSION_ID is missing or is not a session id.";
611
+ const projectDir = env.CLAUDE_PROJECT_DIR ?? process.cwd();
612
+ const remote = spawnSync("git", ["-C", projectDir, "remote", "get-url", "origin"], {
613
+ encoding: "utf8",
614
+ timeout: 1e4,
615
+ env: { ...env, GIT_TERMINAL_PROMPT: "0" }
616
+ });
617
+ const repo = remote.status === 0 ? repoFromRemoteUrl(remote.stdout) : null;
618
+ if (!repo) return "Could not read owner/name from this repository's origin remote.";
619
+ const token = env.POWERPLANT_CAPTURE_TOKEN?.trim() || null;
620
+ if (token !== null && !TOKEN_PATTERN.test(token)) return "POWERPLANT_CAPTURE_TOKEN has characters a token never has.";
621
+ const apiUrl = (env.POWERPLANT_API_URL ?? DEFAULT_API_URL).replace(/\/+$/, "");
622
+ return { transcriptPath, cloudSessionId, repo, apiUrl, token };
623
+ }
624
+ function readFileIfPresent(file) {
625
+ try {
626
+ return readFileSync(file, "utf8");
627
+ } catch {
628
+ return "";
629
+ }
630
+ }
631
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
632
+ async function waitForQuietTranscript(transcriptPath) {
633
+ const startedAt = Date.now();
634
+ for (; ; ) {
635
+ const quietFor = Date.now() - Math.max(startedAt, statSync(transcriptPath).mtimeMs);
636
+ const remaining = startedAt + TRANSCRIPT_WAIT_MS - Date.now();
637
+ if (quietFor >= TRANSCRIPT_QUIET_MS || remaining <= 0) return;
638
+ await sleep(Math.min(TRANSCRIPT_QUIET_MS - quietFor, remaining));
639
+ }
640
+ }
641
+ function runCurl(config) {
642
+ return new Promise((resolve) => {
643
+ const curl = spawn("curl", ["-q", "--config", "-"], { stdio: ["pipe", "pipe", "pipe"] });
644
+ let stdout = "";
645
+ let stderr = "";
646
+ curl.stdout.on("data", (chunk) => stdout += chunk.toString("utf8"));
647
+ curl.stderr.on("data", (chunk) => stderr += chunk.toString("utf8"));
648
+ curl.on("error", (error) => resolve({ exitCode: null, stdout, stderr: error.message }));
649
+ curl.on("close", (exitCode) => resolve({ exitCode, stdout, stderr }));
650
+ curl.stdin.end(config);
651
+ });
652
+ }
653
+ async function upload(input, stateDir) {
654
+ await waitForQuietTranscript(input.transcriptPath);
655
+ if (statSync(input.transcriptPath).size > MAX_CLOUD_TRANSCRIPT_BYTES) {
656
+ return { result: "failed", next: "stop", message: "The transcript is larger than Powerplant accepts." };
657
+ }
658
+ const redacted = redactCloudCaptureTokens(redactJsonl(readFileSync(input.transcriptPath, "utf8")));
659
+ const gzipped = gzipSync(redacted);
660
+ if (gzipped.byteLength > MAX_GZIPPED_BYTES) {
661
+ return { result: "failed", next: "stop", message: "The transcript is larger than Powerplant accepts." };
662
+ }
663
+ const workDir = mkdtempSync(path.join(stateDir, "upload-"));
664
+ try {
665
+ const bodyPath = path.join(workDir, "transcript.jsonl.gz");
666
+ const responsePath = path.join(workDir, "response.json");
667
+ writeFileSync(bodyPath, gzipped, { mode: 384 });
668
+ const curl = await runCurl(
669
+ curlConfig({
670
+ url: `${input.apiUrl}/api/ingest/cloud-sessions/raw`,
671
+ cloudSessionId: input.cloudSessionId,
672
+ repo: input.repo,
673
+ bodyPath,
674
+ responsePath,
675
+ token: input.token
676
+ })
677
+ );
678
+ const httpStatus = Number.parseInt(curl.stdout.trim(), 10);
679
+ if (curl.exitCode !== 0 || !Number.isFinite(httpStatus) || httpStatus === 0) {
680
+ const detail = curl.stderr.trim().split("\n")[0]?.slice(0, 300) || `curl exited with ${curl.exitCode}`;
681
+ return { result: "failed", next: "continue", message: `The upload did not reach Powerplant: ${detail}` };
682
+ }
683
+ return uploadOutcome(httpStatus, readFileIfPresent(responsePath));
684
+ } finally {
685
+ rmSync(workDir, { recursive: true, force: true });
686
+ }
687
+ }
688
+ async function runCloudCaptureHook(env, stdin) {
689
+ if (env.CLAUDE_CODE_REMOTE !== "true") return;
690
+ const stateDir = path.join(os.tmpdir(), "powerplant-cloud-capture");
691
+ mkdirSync(stateDir, { recursive: true, mode: 448 });
692
+ const record = (entry) => {
693
+ const line = JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
694
+ appendFileSync(path.join(stateDir, "results.jsonl"), `${line}
695
+ `);
696
+ process.stderr.write(`powerplant-cloud-capture: ${line}
697
+ `);
698
+ };
699
+ const input = readHookInput(env, stdin);
700
+ if (typeof input === "string") {
701
+ record({ result: "failed", message: input });
702
+ return;
703
+ }
704
+ const stopMarker = path.join(stateDir, `${input.cloudSessionId}.stopped`);
705
+ const failuresFile = path.join(stateDir, `${input.cloudSessionId}.failures`);
706
+ try {
707
+ statSync(stopMarker);
708
+ return;
709
+ } catch {
710
+ }
711
+ let outcome;
712
+ try {
713
+ outcome = await upload(input, stateDir);
714
+ } catch (error) {
715
+ outcome = {
716
+ result: "failed",
717
+ next: "continue",
718
+ message: `The hook could not prepare the upload: ${error instanceof Error ? error.message : String(error)}`
719
+ };
720
+ }
721
+ if (outcome.result === "failed" && outcome.next === "continue") {
722
+ const failures = (Number.parseInt(readFileIfPresent(failuresFile), 10) || 0) + 1;
723
+ writeFileSync(failuresFile, String(failures));
724
+ if (failures >= MAX_FAILED_TURNS) {
725
+ outcome = { ...outcome, next: "stop", message: `${outcome.message} (${failures} failed turns in a row)` };
726
+ }
727
+ } else {
728
+ rmSync(failuresFile, { force: true });
729
+ }
730
+ record({ cloudSessionId: input.cloudSessionId, repo: input.repo, ...outcome });
731
+ if (outcome.next === "stop") writeFileSync(stopMarker, `${outcome.message}
732
+ `);
733
+ }
734
+ async function main() {
735
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
736
+ const chunks = [];
737
+ for await (const chunk of process.stdin) chunks.push(chunk);
738
+ try {
739
+ await runCloudCaptureHook(process.env, Buffer.concat(chunks).toString("utf8"));
740
+ } catch (error) {
741
+ process.stderr.write(`powerplant-cloud-capture: ${error instanceof Error ? error.message : String(error)}
742
+ `);
743
+ }
744
+ }
745
+
746
+ // <stdin>
747
+ await main();