in2-agent-server 0.20260821.185429

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +369 -0
  2. package/agents/plan-requirement.md +32 -0
  3. package/dist/campfire/configItem.js +201 -0
  4. package/dist/campfire/configItemDescription.js +379 -0
  5. package/dist/campfire/deriveFromBundle.js +1013 -0
  6. package/dist/cli.js +465 -0
  7. package/dist/core/betaFeedback.js +493 -0
  8. package/dist/core/chapterInstructions.js +473 -0
  9. package/dist/core/claudeAssets.js +61 -0
  10. package/dist/core/gate.js +201 -0
  11. package/dist/core/ids.js +9 -0
  12. package/dist/core/install.js +60 -0
  13. package/dist/core/operatorQa.js +462 -0
  14. package/dist/core/orgCallMeter.js +178 -0
  15. package/dist/core/parcels.js +1235 -0
  16. package/dist/core/paths.js +105 -0
  17. package/dist/core/phases.js +225 -0
  18. package/dist/core/planFile.js +251 -0
  19. package/dist/core/planFragments.js +119 -0
  20. package/dist/core/readsLog.js +398 -0
  21. package/dist/core/registration.js +24 -0
  22. package/dist/core/result.js +38 -0
  23. package/dist/core/resume.js +233 -0
  24. package/dist/core/revisionChain.js +351 -0
  25. package/dist/core/richText.js +61 -0
  26. package/dist/core/semanticHash.js +89 -0
  27. package/dist/core/sessionOrg.js +60 -0
  28. package/dist/core/sfQuery.js +19 -0
  29. package/dist/core/spawn.js +182 -0
  30. package/dist/core/supervisorContract.js +230 -0
  31. package/dist/core/telemetry.js +1297 -0
  32. package/dist/core/telemetryContext.js +154 -0
  33. package/dist/core/telemetrySpool.js +244 -0
  34. package/dist/core/transcriptUpload.js +516 -0
  35. package/dist/core/update.js +363 -0
  36. package/dist/core/viewer.js +117 -0
  37. package/dist/core/workspace.js +92 -0
  38. package/dist/core/writeGrants.js +93 -0
  39. package/dist/core/writebackManifest.js +92 -0
  40. package/dist/direct/applyLoop.js +2516 -0
  41. package/dist/direct/batchReport.js +510 -0
  42. package/dist/direct/comparatorNoise.js +75 -0
  43. package/dist/direct/containers.js +85 -0
  44. package/dist/direct/contamination.js +221 -0
  45. package/dist/direct/crud.js +56 -0
  46. package/dist/direct/destructive.js +168 -0
  47. package/dist/direct/knownTokens.js +94 -0
  48. package/dist/direct/lint.js +382 -0
  49. package/dist/direct/metadataSchema.generated.json +1 -0
  50. package/dist/direct/metadataSchema.js +179 -0
  51. package/dist/direct/operatorPage.js +349 -0
  52. package/dist/direct/orgRepo.js +1531 -0
  53. package/dist/direct/orgRepoSpawnCount.js +21 -0
  54. package/dist/direct/plannedScope.js +119 -0
  55. package/dist/direct/preImageTargets.js +63 -0
  56. package/dist/direct/refVerify.js +191 -0
  57. package/dist/direct/registry.js +129 -0
  58. package/dist/direct/removals.js +262 -0
  59. package/dist/direct/rollback.js +1513 -0
  60. package/dist/direct/rollbackRun.js +1360 -0
  61. package/dist/direct/sessionProjection.js +126 -0
  62. package/dist/direct/snapshotComparability.js +165 -0
  63. package/dist/direct/sourceTree.js +113 -0
  64. package/dist/direct/unstatedChanges.js +106 -0
  65. package/dist/direct/waveAssembly.js +556 -0
  66. package/dist/direct/waveCounts.js +19 -0
  67. package/dist/direct/waveDerivation.js +152 -0
  68. package/dist/direct/waveGrade.js +185 -0
  69. package/dist/index.js +90 -0
  70. package/dist/orgread/customFieldSnapshot.js +48 -0
  71. package/dist/orgread/fieldMetadata.js +178 -0
  72. package/dist/orgread/genericSnapshot.js +61 -0
  73. package/dist/orgread/layoutSnapshot.js +151 -0
  74. package/dist/orgread/salesforceOrgReader.js +184 -0
  75. package/dist/orgread/snapshotTypes.js +9 -0
  76. package/dist/projection/bridge.js +26 -0
  77. package/dist/projection/diff.js +141 -0
  78. package/dist/projection/mapmd.js +213 -0
  79. package/dist/projection/nameAmbiguity.js +106 -0
  80. package/dist/projection/precedent.js +136 -0
  81. package/dist/projection/project.js +214 -0
  82. package/dist/projection/scope.js +110 -0
  83. package/dist/projection/types.js +5 -0
  84. package/dist/tools/analyze.js +322 -0
  85. package/dist/tools/auth.js +915 -0
  86. package/dist/tools/campfire.js +1742 -0
  87. package/dist/tools/closeBatch.js +154 -0
  88. package/dist/tools/closeText.js +35 -0
  89. package/dist/tools/direct.js +1613 -0
  90. package/dist/tools/feedback.js +318 -0
  91. package/dist/tools/operator.js +287 -0
  92. package/dist/tools/org.js +739 -0
  93. package/dist/tools/phase.js +249 -0
  94. package/dist/tools/projection.js +172 -0
  95. package/dist/tools/session.js +313 -0
  96. package/dist/tools/update.js +82 -0
  97. package/dist/tools/waves.js +793 -0
  98. package/package.json +48 -0
  99. package/reference/campfire-configuration-items-guide.md +710 -0
  100. package/reference/deployability-cribs.md +119 -0
  101. package/reference/operator-voice.md +141 -0
  102. package/reference/org-read-cribs.md +34 -0
  103. package/reference/scoped-dml-interim.md +68 -0
  104. package/reference/supervisor-playbook.md +313 -0
  105. package/reference/templates.md +233 -0
  106. package/skills/in2-delivery/SKILL.md +322 -0
  107. package/skills/in2-delivery/references/campfire-configuration-items-guide.md +710 -0
  108. package/skills/in2-delivery/references/deployability-cribs.md +119 -0
  109. package/skills/in2-delivery/references/operator-voice.md +141 -0
  110. package/skills/in2-delivery/references/org-read-cribs.md +34 -0
  111. package/skills/in2-delivery/references/scoped-dml-interim.md +68 -0
  112. package/skills/in2-delivery/references/templates.md +233 -0
  113. package/skills/plan-requirement/SKILL.md +188 -0
  114. package/viewer/index.html +899 -0
  115. package/viewer/server.mjs +433 -0
@@ -0,0 +1,516 @@
1
+ import { createReadStream, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { createGzip } from "node:zlib";
5
+ import { pipeline } from "node:stream/promises";
6
+ // FULL session transcript capture — ON BY DEFAULT since 2026-07-31; CC_MCP_TRANSCRIPT_UPLOAD=0 opts
7
+ // out. (It shipped off-by-default and this line went on saying so for longer than the default held;
8
+ // `transcriptUploadEnabled` below is the authority, and it treats an unset variable as ON.)
9
+ //
10
+ // Why this is separate from telemetry rather than a flag on it. Telemetry ships shapes: tool name,
11
+ // outcome, duration, never a metadata body, and the install prompt a consultant approves is written
12
+ // to match. A Claude Code transcript is content — every prompt, every tool result, every record the
13
+ // model read, and anything pasted into the chat, which on real machines has included activation
14
+ // codes. Letting that ride on telemetry's consent would make the prompt a lie.
15
+ //
16
+ // So: its own env var and its own endpoint, never a flag on telemetry's consent — and, since the
17
+ // default was inverted, `announce()` states on stderr at EVERY startup that the whole session is
18
+ // going to be sent and how to stop it. That announcement is what carries the disclosure the
19
+ // off-by-default used to carry; the install prompt (routes/callback.ts) now names transcript upload
20
+ // as part of what installing grants.
21
+ //
22
+ // Nothing here is load-bearing for delivery. Every failure path returns a reason and gives up.
23
+ /** The slug length past which Claude Code truncates and appends a hash. `A2e` in the 2.1.221 binary. */
24
+ const CLAUDE_SLUG_MAX = 200;
25
+ /**
26
+ * Claude Code's hash of a project path — JavaScript's ordinary signed 32-bit string hash.
27
+ *
28
+ * Transcribed from the 2.1.221 binary rather than reconstructed from its behaviour:
29
+ *
30
+ * function nat(e){let t=0;for(let r=0;r<e.length;r++)t=(t<<5)-t+e.charCodeAt(r)|0;return t}
31
+ * function Lig(e){return Math.abs(nat(e)).toString(36)}
32
+ *
33
+ * The `|0` is load-bearing and not a tidy-up: it forces the signed 32-bit wrap on every iteration,
34
+ * which is what makes the result reproducible here at all. Applied to the ORIGINAL path, never the
35
+ * replaced slug — two different paths can reduce to the same 200-character prefix, and this is the
36
+ * only thing that tells them apart.
37
+ */
38
+ function claudePathHash(cwd) {
39
+ let hash = 0;
40
+ for (let i = 0; i < cwd.length; i += 1)
41
+ hash = ((hash << 5) - hash + cwd.charCodeAt(i)) | 0;
42
+ return Math.abs(hash).toString(36);
43
+ }
44
+ /**
45
+ * Claude Code's project-directory slug, as `GC(e)` computes it in the 2.1.221 binary:
46
+ *
47
+ * function GC(e){let t=e.replace(/[^a-zA-Z0-9]/g,"-");if(t.length<=A2e)return t;
48
+ * return `${t.slice(0,A2e)}-${Lig(e)}`} // A2e=200
49
+ *
50
+ * TWO RULES, AND ONLY THE FIRST HAD EVER BEEN IMPLEMENTED HERE.
51
+ *
52
+ * (1) EVERY NON-ALPHANUMERIC BYTE becomes `-`. This module replaced `[/\\]` only, and that is what
53
+ * made the whole feature look like it had never shipped. Measured against the live ledger on
54
+ * 2026-08-06: 19 batches across 6 installs ran a build that could upload and 4 uploaded, all from
55
+ * ONE install — and those four were the only runs whose cwd happened to be alphanumeric-plus-
56
+ * slashes (`~/Projects/in2-runs`, `~/Projects/oss`), where the broken rule is accidentally correct.
57
+ * Every other run resolved to a directory that does not exist, found no transcript, and gave up
58
+ * with a reason that went nowhere. A single dot was enough: `.claude/worktrees/…`, a folder named
59
+ * `acme.demo`, a space in `Config Agent Folder` (live-caught 2026-08-03, PR #78), or a Windows
60
+ * drive colon, which is not a separator and so survived slash-replacement as `C:`.
61
+ *
62
+ * (2) A SLUG OVER 200 CHARACTERS is truncated to 200 and given `-<hash of the original path>`.
63
+ * From PR #78, verified here against the binary. This one is LATENT, not observed: the longest slug
64
+ * in this machine's `~/.claude/projects/` is 140 characters and none of the 72 recoverable paths
65
+ * reaches the cap. It is carried because a consultant's path is not ours to predict — a deep
66
+ * worktree, a Windows profile directory, a long client folder name — and because missing it
67
+ * produces exactly the silent no-transcript failure this module exists to end.
68
+ *
69
+ * Checked against this machine's real `~/.claude/projects/` (2026-08-07): the old rule reproduced
70
+ * 17 of 72 directories; this one reproduces 68. The remaining 4 are worktrees renamed after the
71
+ * fact, whose stored cwd names a different directory entirely — no encoder recovers those, and
72
+ * nothing here pretends to.
73
+ *
74
+ * NOT IMPLEMENTED, deliberately: the binary's `PN(e)` ALSO scans the projects root for directories
75
+ * beginning with the truncated prefix, so it can find a transcript whose hash no longer matches
76
+ * (a path that moved). That is a recovery path for a case nothing here has ever hit, and the flat
77
+ * scan it would add is the cost `findSubagentTranscripts` was explicitly written to avoid. If a
78
+ * capped path is ever observed to miss, that is the next thing to add.
79
+ */
80
+ function claudeProjectSlug(cwd) {
81
+ const replaced = cwd.replace(/[^a-zA-Z0-9]/g, "-");
82
+ if (replaced.length <= CLAUDE_SLUG_MAX)
83
+ return replaced;
84
+ return `${replaced.slice(0, CLAUDE_SLUG_MAX)}-${claudePathHash(cwd)}`;
85
+ }
86
+ /** Claude Code writes each session to `~/.claude/projects/<slug>/<uuid>.jsonl`. */
87
+ export function transcriptDirFor(cwd, home = os.homedir()) {
88
+ return path.join(home, ".claude", "projects", claudeProjectSlug(cwd));
89
+ }
90
+ /** A session's subagent transcripts live one directory down: <projectDir>/<sessionUuid>/subagents/ */
91
+ export function subagentDirFor(cwd, claudeSessionId, home = os.homedir()) {
92
+ return path.join(transcriptDirFor(cwd, home), claudeSessionId, "subagents");
93
+ }
94
+ export function isSubagentTranscript(t) {
95
+ return typeof t.agentId === "string";
96
+ }
97
+ /**
98
+ * The transcript for the session this server is serving.
99
+ *
100
+ * The MCP server is spawned BY Claude Code and inherits its working directory, so the project
101
+ * directory resolves exactly. Which file inside it is a heuristic — the newest by mtime — because
102
+ * an MCP server is told nothing about its session id. That is right in practice: the live session
103
+ * is the one being appended to. It is wrong if two sessions share a cwd and the other one is busier,
104
+ * which is why the result carries the id and the caller records it.
105
+ */
106
+ export function findTranscript(cwd, home = os.homedir()) {
107
+ const dir = transcriptDirFor(cwd, home);
108
+ if (!existsSync(dir))
109
+ return null;
110
+ let best = null;
111
+ for (const name of readdirSync(dir)) {
112
+ if (!name.endsWith(".jsonl"))
113
+ continue;
114
+ try {
115
+ const st = statSync(path.join(dir, name));
116
+ if (!st.isFile())
117
+ continue;
118
+ if (!best || st.mtimeMs > best.modifiedMs) {
119
+ best = {
120
+ file: path.join(dir, name),
121
+ claudeSessionId: name.replace(/\.jsonl$/, ""),
122
+ bytes: st.size,
123
+ modifiedMs: st.mtimeMs
124
+ };
125
+ }
126
+ }
127
+ catch {
128
+ // Unreadable entry — skip it rather than fail the sweep.
129
+ }
130
+ }
131
+ return best;
132
+ }
133
+ /**
134
+ * Every transcript in this project that references the IN2 session — matched on content, not mtime.
135
+ *
136
+ * The first version of this picked the newest file and called it "the" transcript. Checking that
137
+ * against real data (2026-07-31) showed the assumption was wrong in kind, not degree: one IN2
138
+ * session id appeared in THREE Claude Code transcripts at once — 190, 195 and 40 occurrences —
139
+ * because parallel agent sessions share a session. There is no single transcript to find.
140
+ *
141
+ * Matching is a plain substring scan for the session id, which every org-touching tool call carries
142
+ * as an argument and Claude Code therefore records as tool input. The activation code works too and
143
+ * is accepted as a marker, but is deliberately never logged or stored.
144
+ *
145
+ * Newest first, so a caller that wants only the primary transcript can take the head.
146
+ */
147
+ export function findTranscriptsForSession(cwd, markers, home = os.homedir(), opts = {}) {
148
+ const dir = transcriptDirFor(cwd, home);
149
+ const usable = markers.filter((m) => typeof m === "string" && m.length >= 8);
150
+ if (!existsSync(dir) || usable.length === 0)
151
+ return [];
152
+ const maxFiles = opts.maxFiles ?? 40;
153
+ const budget = opts.maxBytesScanned ?? 2 * 1024 * 1024 * 1024;
154
+ const candidates = [];
155
+ for (const name of readdirSync(dir)) {
156
+ if (!name.endsWith(".jsonl"))
157
+ continue;
158
+ try {
159
+ const st = statSync(path.join(dir, name));
160
+ if (!st.isFile())
161
+ continue;
162
+ candidates.push({ file: path.join(dir, name), claudeSessionId: name.replace(/\.jsonl$/, ""), bytes: st.size, modifiedMs: st.mtimeMs });
163
+ }
164
+ catch {
165
+ // Unreadable entry — skip.
166
+ }
167
+ }
168
+ candidates.sort((a, b) => b.modifiedMs - a.modifiedMs);
169
+ const matched = [];
170
+ let scanned = 0;
171
+ for (const c of candidates.slice(0, maxFiles)) {
172
+ if (scanned + c.bytes > budget)
173
+ break;
174
+ scanned += c.bytes;
175
+ try {
176
+ // Substring scan, no JSON parsing: these files reach tens of megabytes and the marker is an
177
+ // opaque id, so there is nothing to gain from understanding the structure.
178
+ const text = readFileSync(c.file, "utf8");
179
+ if (usable.some((m) => text.includes(m)))
180
+ matched.push(c);
181
+ }
182
+ catch {
183
+ // Unreadable file — skip rather than fail the sweep.
184
+ }
185
+ }
186
+ return matched;
187
+ }
188
+ /** Defensive cap on how many of one session's agents ship. Far above anything observed. */
189
+ const MAX_SUBAGENTS = 100;
190
+ /**
191
+ * The subagent transcripts belonging to one main session.
192
+ *
193
+ * WHY THIS EXISTS AS A SECOND FUNCTION rather than a flag on the scan above. `findTranscript*`
194
+ * enumerates the project directory flat, and that was not an oversight to fix in place: a recursive
195
+ * walk would content-scan every subagent file of every session the project ever ran looking for the
196
+ * marker, which on this machine is 540 files across 44 sessions for a batch that owns four of them.
197
+ *
198
+ * So the scan stays flat and this runs only under a main transcript the caller has ALREADY matched.
199
+ * The session's own uuid names the directory, so there is nothing to search for: at most one
200
+ * `readdirSync` per shipped main transcript, and no content scan at all.
201
+ *
202
+ * THE SUBAGENT FILES WERE NEVER SHIPPING, and the numbers say what that cost. Claude Code writes a
203
+ * `Task` subagent's turns to `<projectDir>/<sessionUuid>/subagents/agent-<id>.jsonl` and never into
204
+ * the parent file — measured across this machine's stored sessions on 2026-08-03, those files hold
205
+ * 26.0% of `output_tokens` and 13.8% of cache tokens. Every ledger total built from main
206
+ * transcripts alone was understating a fanned-out run by roughly that much.
207
+ *
208
+ * Each file has an `agent-<id>.meta.json` sidecar carrying `{agentType, description, name,
209
+ * toolUseId, spawnDepth}` — present for all 540 of them. It is read here rather than derived later
210
+ * because it exists ONLY on the machine that ran the agents; the transcript itself never states
211
+ * which agent type produced it or what the parent asked for. `toolUseId` is deliberately not
212
+ * carried: it addresses a tool call in a transcript nobody joins on, and the fields that are
213
+ * carried are the ones a ledger reader needs to tell an analyzer from a reviewer.
214
+ *
215
+ * A missing or unparseable sidecar is not a failure — the transcript still ships, with empty meta.
216
+ * Sorted by agent id so a run's uploads are reported in a stable order twice.
217
+ */
218
+ export function findSubagentTranscripts(cwd, claudeSessionId, home = os.homedir(), opts = {}) {
219
+ const dir = subagentDirFor(cwd, claudeSessionId, home);
220
+ if (!existsSync(dir))
221
+ return { agents: [], skipped: 0 };
222
+ const maxAgents = opts.maxAgents ?? MAX_SUBAGENTS;
223
+ const found = [];
224
+ let names;
225
+ try {
226
+ names = readdirSync(dir);
227
+ }
228
+ catch {
229
+ return { agents: [], skipped: 0 };
230
+ }
231
+ for (const name of names.sort()) {
232
+ const agentId = /^agent-(.+)\.jsonl$/.exec(name)?.[1];
233
+ if (!agentId)
234
+ continue;
235
+ const file = path.join(dir, name);
236
+ try {
237
+ const st = statSync(file);
238
+ if (!st.isFile())
239
+ continue;
240
+ found.push({
241
+ file,
242
+ claudeSessionId,
243
+ agentId,
244
+ bytes: st.size,
245
+ modifiedMs: st.mtimeMs,
246
+ meta: readSubagentMeta(path.join(dir, `agent-${agentId}.meta.json`))
247
+ });
248
+ }
249
+ catch {
250
+ // Unreadable entry — skip it rather than fail the sweep.
251
+ }
252
+ }
253
+ // Truncation is REPORTED, never silent: a run whose agents were partly dropped must not read the
254
+ // same as one whose agents all shipped. The caller turns this into an operator-visible note.
255
+ return { agents: found.slice(0, maxAgents), skipped: Math.max(0, found.length - maxAgents) };
256
+ }
257
+ function readSubagentMeta(file) {
258
+ try {
259
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
260
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
261
+ return {};
262
+ const raw = parsed;
263
+ const str = (v) => (typeof v === "string" && v.length > 0 ? v : undefined);
264
+ const meta = {};
265
+ const agentType = str(raw.agentType);
266
+ if (agentType !== undefined)
267
+ meta.agentType = agentType;
268
+ const description = str(raw.description);
269
+ if (description !== undefined)
270
+ meta.description = description;
271
+ const name = str(raw.name);
272
+ if (name !== undefined)
273
+ meta.name = name;
274
+ if (Number.isSafeInteger(raw.spawnDepth) && raw.spawnDepth >= 0)
275
+ meta.spawnDepth = raw.spawnDepth;
276
+ return meta;
277
+ }
278
+ catch {
279
+ // No sidecar, or one that is not JSON. The transcript is still worth shipping without it.
280
+ return {};
281
+ }
282
+ }
283
+ /**
284
+ * ON BY DEFAULT. `CC_MCP_TRANSCRIPT_UPLOAD=0` is the way out.
285
+ *
286
+ * This inverted on 2026-07-31. It shipped opt-in because the payload is categorically unlike the
287
+ * rest of the ledger — shape-only everywhere else, the entire session here — and the default is now
288
+ * the other way because reconstructing what happened between two tool calls is the whole point of
289
+ * UAT and internal runs, and an opt-in nobody sets captures nothing.
290
+ *
291
+ * What has to stay true for that default to be honest, and what the rest of this module enforces:
292
+ * - the install consent text names transcript upload explicitly (supervisorContract.ts). While
293
+ * this was opt-in the prompt deliberately said nothing about it; a default that the consent
294
+ * screen does not mention is the thing to avoid here.
295
+ * - `announce` prints on EVERY startup, says what leaves and how to stop it.
296
+ * - the grant is still the only authority for identity, so a transcript can only ever be
297
+ * attributed to the batch whose credential uploaded it.
298
+ *
299
+ * Accepts "0", "false", "off" and "no" so an operator who reaches for the obvious spelling is not
300
+ * silently still uploading. Anything else — including unset — is on.
301
+ */
302
+ const DISABLED_VALUES = new Set(["0", "false", "off", "no"]);
303
+ export function transcriptUploadEnabled(env = process.env) {
304
+ const raw = env.CC_MCP_TRANSCRIPT_UPLOAD?.trim().toLowerCase();
305
+ return raw === undefined || raw === "" ? true : !DISABLED_VALUES.has(raw);
306
+ }
307
+ /**
308
+ * Say it out loud, every startup, in the operator's language.
309
+ *
310
+ * A capability that sends the whole session somewhere must never be discoverable only by reading
311
+ * source or env. If someone enabled this on a machine that later ran real client work, this line in
312
+ * the log is what makes that recoverable rather than invisible.
313
+ */
314
+ export function announce(endpoint, log = (l) => process.stderr.write(`${l}\n`)) {
315
+ log(`[cc-mcp] FULL TRANSCRIPT UPLOAD IS ON (the default). The complete session — every prompt, ` +
316
+ `tool result and record read, including anything pasted into the chat, and the transcript of ` +
317
+ `every subagent this session dispatches — will be sent to ` +
318
+ `${endpoint ?? "the configured endpoint"} when this session ends. ` +
319
+ `Set CC_MCP_TRANSCRIPT_UPLOAD=0 to stop this.`);
320
+ }
321
+ /** gzip the transcript to a temp file. Streamed: a long session is tens of megabytes. */
322
+ export async function packTranscript(file, tmpDir = os.tmpdir()) {
323
+ const out = path.join(tmpDir, `in2-transcript-${path.basename(file)}.gz`);
324
+ const { createWriteStream } = await import("node:fs");
325
+ await pipeline(createReadStream(file), createGzip(), createWriteStream(out));
326
+ return { path: out, bytes: statSync(out).size };
327
+ }
328
+ /**
329
+ * A meta value made safe to put in an HTTP header.
330
+ *
331
+ * `description` and `name` are MODEL-AUTHORED — the parent agent wrote them when it spawned the
332
+ * Task — so they are free text reaching a header field, where a newline is a header injection and a
333
+ * non-Latin-1 byte makes `fetch` throw and take the whole upload with it. Reduced to printable
334
+ * ASCII and capped, and an empty result means the header is simply not sent.
335
+ */
336
+ function headerSafe(value, max = 200) {
337
+ if (value === undefined)
338
+ return undefined;
339
+ const cleaned = value.replace(/[^\x20-\x7E]+/g, " ").trim().slice(0, max).trim();
340
+ return cleaned.length > 0 ? cleaned : undefined;
341
+ }
342
+ /**
343
+ * The subagent headers, or `undefined` when the id itself cannot be one.
344
+ *
345
+ * THE ID GOES THROUGH THE SAME REDUCTION AS ITS SIBLINGS. It is not model-authored, but it is not
346
+ * validated either: it comes off a FILENAME — `/^agent-(.+)\.jsonl$/` over a directory entry — and
347
+ * a POSIX filename may hold every byte but `/` and NUL. Left raw it reached `fetch`, where one
348
+ * control byte throws inside the upload's try and degrades a perfectly readable transcript to
349
+ * "subagent transcript upload failed". The server caps this field at 128, so the client does too.
350
+ *
351
+ * NOTHING LEFT AFTER THE REDUCTION IS A REFUSAL, not a bare upload. `x-in2-agent-id` is the field
352
+ * that makes an upload a subagent: absent, the server records a MAIN row, and `record` then
353
+ * supersedes — deletes — the session's real main transcript with an analyzer's file.
354
+ */
355
+ function subagentHeaders(t) {
356
+ const agentId = headerSafe(t.agentId, 128);
357
+ if (agentId === undefined)
358
+ return undefined;
359
+ const headers = { "x-in2-agent-id": agentId };
360
+ const agentType = headerSafe(t.meta.agentType, 64);
361
+ if (agentType)
362
+ headers["x-in2-agent-type"] = agentType;
363
+ const description = headerSafe(t.meta.description);
364
+ if (description)
365
+ headers["x-in2-agent-description"] = description;
366
+ const name = headerSafe(t.meta.name, 64);
367
+ if (name)
368
+ headers["x-in2-agent-name"] = name;
369
+ if (t.meta.spawnDepth !== undefined)
370
+ headers["x-in2-agent-spawn-depth"] = String(t.meta.spawnDepth);
371
+ return headers;
372
+ }
373
+ /**
374
+ * Ship it. The grant is the same batch-scoped credential telemetry uses, so a transcript can only
375
+ * ever be attributed to the batch whose credential sent it — the server derives identity from the
376
+ * grant and ignores anything the client claims.
377
+ *
378
+ * ONE UPLOAD PER FILE, subagents included, through this same route: same grant, same gzip, same
379
+ * 20 MB refuse-don't-truncate rule. A subagent adds headers and changes nothing else — it is a
380
+ * Claude Code transcript in every respect the server cares about, and giving it a second route
381
+ * would mean a second size rule and a second identity check to keep in step with this one.
382
+ */
383
+ export async function uploadTranscript(opts) {
384
+ const maxBytes = opts.maxBytes ?? 20 * 1024 * 1024;
385
+ const fetchFn = opts.fetchFn ?? fetch;
386
+ // Before the compression, so a subagent that cannot be named does not cost a gzip of a file that
387
+ // is never going to be sent.
388
+ const subagent = isSubagentTranscript(opts.transcript) ? subagentHeaders(opts.transcript) : {};
389
+ if (subagent === undefined) {
390
+ return {
391
+ uploaded: false,
392
+ reason: `subagent agent id is empty once reduced to header-safe text — it stays on this machine at ${opts.transcript.file}`
393
+ };
394
+ }
395
+ let packed;
396
+ try {
397
+ packed = await packTranscript(opts.transcript.file, opts.tmpDir);
398
+ }
399
+ catch (error) {
400
+ return { uploaded: false, reason: `could not compress transcript: ${error instanceof Error ? error.message : String(error)}` };
401
+ }
402
+ if (packed.bytes > maxBytes) {
403
+ // Refuse rather than truncate. A partial transcript that looks whole is worse than none: it
404
+ // would be analysed as if the missing turns never happened.
405
+ return {
406
+ uploaded: false,
407
+ reason: `compressed transcript is ${Math.round(packed.bytes / 1024 / 1024)} MB, over the ${Math.round(maxBytes / 1024 / 1024)} MB limit — it stays on this machine at ${opts.transcript.file}`
408
+ };
409
+ }
410
+ try {
411
+ const { readFileSync } = await import("node:fs");
412
+ const res = await fetchFn(opts.endpoint, {
413
+ method: "POST",
414
+ headers: {
415
+ "content-type": "application/gzip",
416
+ authorization: `Bearer ${opts.credential}`,
417
+ "x-in2-transcript-raw-bytes": String(opts.transcript.bytes),
418
+ "x-in2-claude-session-id": opts.transcript.claudeSessionId,
419
+ ...subagent,
420
+ ...(opts.recoveredReason ? { "x-in2-transcript-recovered": opts.recoveredReason } : {})
421
+ },
422
+ body: readFileSync(packed.path),
423
+ signal: AbortSignal.timeout(120_000)
424
+ });
425
+ if (!res.ok)
426
+ return { uploaded: false, reason: `HTTP ${res.status}` };
427
+ const body = (await res.json().catch(() => ({})));
428
+ // A 2xx MEANS "PROCESSED", NOT "KEPT". The ledger declines an upload that would replace a
429
+ // larger capture of the same (session, agent) with a smaller one, and says so in the body.
430
+ // Reading only the status here would report that as a successful upload — and "the client said
431
+ // it shipped" being true while the ledger held something else is the exact failure this whole
432
+ // path was fixed for. Not an error: nothing is lost, the better capture is already on file.
433
+ if (body.stored === false) {
434
+ return {
435
+ uploaded: false,
436
+ reason: `ledger kept the larger capture it already holds${body.reason ? ` (${body.reason})` : ""} — nothing was lost`
437
+ };
438
+ }
439
+ return { uploaded: true, storedBytes: body.storedBytes ?? packed.bytes };
440
+ }
441
+ catch (error) {
442
+ return { uploaded: false, reason: error instanceof Error ? error.message : String(error) };
443
+ }
444
+ finally {
445
+ const { rmSync } = await import("node:fs");
446
+ try {
447
+ rmSync(packed.path, { force: true });
448
+ }
449
+ catch {
450
+ // Temp file cleanup is best effort.
451
+ }
452
+ }
453
+ }
454
+ /**
455
+ * Ship a session's transcripts — the main file, then every agent it dispatched — and say what went.
456
+ *
457
+ * HERE RATHER THAN IN `telemetry.ts` because it is upload orchestration, and it belongs beside the
458
+ * two functions it orchestrates. Discovery stays with the caller: which main transcripts belong to
459
+ * this batch is a session-marker question that only `telemetry.ts` holds the markers for.
460
+ *
461
+ * THE SUMMARY LINE IS THE POINT OF THE COUNTING. A run that fanned out to 40 agents would bury the
462
+ * rest of the closing report if each success got a line, and a run whose agents all silently failed
463
+ * to upload would look identical to one that dispatched none. So: successes are counted, failures
464
+ * are named individually, and the count is stated even when it is zero.
465
+ */
466
+ export async function shipSessionTranscripts(opts) {
467
+ const cwd = opts.cwd ?? process.cwd();
468
+ const home = opts.home ?? os.homedir();
469
+ const upload = opts.upload ?? uploadTranscript;
470
+ const notes = [];
471
+ let mainUploaded = 0;
472
+ let subUploaded = 0;
473
+ let subSkipped = 0;
474
+ for (const transcript of opts.transcripts) {
475
+ const result = await upload({ endpoint: opts.endpoint, credential: opts.credential, transcript });
476
+ if (result.uploaded)
477
+ mainUploaded += 1;
478
+ notes.push(result.uploaded
479
+ ? `transcript uploaded ${transcript.claudeSessionId} (${Math.round((result.storedBytes ?? 0) / 1024)} KB compressed from ${Math.round(transcript.bytes / 1024)} KB)`
480
+ : `transcript upload failed for ${transcript.claudeSessionId}: ${result.reason}`);
481
+ // The agents this session fanned out to. Enumerated ONLY under a main transcript that already
482
+ // matched, so the cost is one readdir per shipped file rather than a recursive walk.
483
+ //
484
+ // Attempted even when the main upload failed: the two are independent captures, and an oversize
485
+ // parent is no reason to leave the batch's analysis phase unrecorded as well.
486
+ const { agents, skipped } = findSubagentTranscripts(cwd, transcript.claudeSessionId, home, {
487
+ ...(opts.maxAgents !== undefined ? { maxAgents: opts.maxAgents } : {})
488
+ });
489
+ subSkipped += skipped;
490
+ for (const agent of agents) {
491
+ const agentResult = await upload({ endpoint: opts.endpoint, credential: opts.credential, transcript: agent });
492
+ if (agentResult.uploaded)
493
+ subUploaded += 1;
494
+ else
495
+ notes.push(`subagent transcript upload failed for ${agent.agentId}: ${agentResult.reason}`);
496
+ }
497
+ }
498
+ // Always plural. The noun covers both terms, so agreeing it with the total gives "1 main + 0
499
+ // subagent transcript" — a sentence whose grammar argues with the zero standing next to it.
500
+ notes.push(`uploaded ${mainUploaded} main + ${subUploaded} subagent transcripts`);
501
+ if (subSkipped > 0) {
502
+ notes.push(`${subSkipped} further subagent transcript(s) were NOT uploaded — this session exceeded the per-session cap`);
503
+ }
504
+ return notes;
505
+ }
506
+ /** Derive the transcript endpoint from the telemetry one — same host, same grant, different route. */
507
+ export function transcriptEndpointFrom(telemetryEndpoint) {
508
+ try {
509
+ const u = new URL(telemetryEndpoint);
510
+ u.pathname = "/api/transcript/v1";
511
+ return u.toString();
512
+ }
513
+ catch {
514
+ return telemetryEndpoint.replace(/\/api\/telemetry\/v\d+$/, "/api/transcript/v1");
515
+ }
516
+ }