cc-viewer 1.6.348 → 1.7.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.
- package/README.md +13 -1
- package/cli.js +116 -37
- package/dist/assets/App-DTkacfRW.js +2 -0
- package/dist/assets/{MdxEditorPanel-ualoRFOL.js → MdxEditorPanel-C5Q6uUyH.js} +1 -1
- package/dist/assets/Mobile-MoBp9c8k.js +1 -0
- package/dist/assets/{_baseUniq-CG81mgN_.js → _baseUniq-D9_E5ZDp.js} +1 -1
- package/dist/assets/{arc-C161J4f2.js → arc-D4iFrlhb.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BmbDMdn9.js → architectureDiagram-Q4EWVU46-f1neo7hg.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-DXpMvdxd.js → blockDiagram-DXYQGD6D-DWi9e9tG.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-8Mypg0IU.js → c4Diagram-AHTNJAMY-DAdj49A2.js} +1 -1
- package/dist/assets/{channel-B2khdmvc.js → channel-B3VthT0m.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-I61r1g6z.js → chunk-4BX2VUAB-BrPcAHV3.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-VFPlRE5E.js → chunk-4TB4RGXK-Bvopc1V9.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-DvCamg_6.js → chunk-55IACEB6-Juw-7Z3i.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-Dj4Q8RwR.js → chunk-EDXVE4YY-DVYP9tpB.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-_bgQuuJh.js → chunk-FMBD7UC4-VQ7VOXl3.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-3to38LyN.js → chunk-OYMX7WX6-F8Rj4ZUZ.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Zl26wKKz.js → chunk-QZHKN3VN-Dn7K3yi9.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-RLYPrlh0.js → chunk-YZCP3GAM-UjO-KTsn.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +1 -0
- package/dist/assets/clone-BQtvy9Ae.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-CsYOjJ0V.js → cose-bilkent-S5V4N54A-BDhNj8rq.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-C3YXXaFU.js → dagre-KV5264BT-BzdcDuyt.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DUCOg1ld.js → diagram-5BDNPKRD-DP_SX65N.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-BtlLl_zX.js → diagram-G4DWMVQ6-B-pBFw8Q.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-1Qp9xfOk.js → diagram-MMDJMWI5-6XSfN1eN.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Cj7VMp3L.js → diagram-TYMM5635-DJvGxvpE.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-CVZVn541.js → erDiagram-SMLLAGMA-CpqdpGYj.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-CiU9Fkkv.js → flowDiagram-DWJPFMVM-C9m7wjwI.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-aRaG97ID.js → ganttDiagram-T4ZO3ILL-CU0XOfY3.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Cr2LJlvX.js → gitGraphDiagram-UUTBAWPF-ipyGULnP.js} +1 -1
- package/dist/assets/{graph-B59lDyMn.js → graph-BR2KmES9.js} +1 -1
- package/dist/assets/{index-C1ptnCpq.js → index--wgRW_OB.js} +1 -1
- package/dist/assets/index-Ac8vnV3v.js +2 -0
- package/dist/assets/{index-CRAWA8PC.js → index-C-be29ey.js} +1 -1
- package/dist/assets/{index-CSM1Kw8l.js → index-C16fiVhv.js} +1 -1
- package/dist/assets/{index-Ddk-kg22.js → index-D5YruOKD.js} +1 -1
- package/dist/assets/{index-BnhNIjbj.js → index-D5tSeMU7.js} +1 -1
- package/dist/assets/{index-vFEQq-NB.js → index-DuZQ6FqR.js} +1 -1
- package/dist/assets/{index-CO2hZ1s4.js → index-OWzSX2T8.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-CnO22bw8.js → infoDiagram-42DDH7IO-BQWQjfFX.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-ByNzpScH.js → ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-yaVaCWPq.js → journeyDiagram-VCZTEJTY-CMbB_4G9.js} +1 -1
- package/dist/assets/{jszip.min-BdVFP3OW.js → jszip.min-B6k2t9Ch.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-RIPR_RQ3.js → kanban-definition-6JOO6SKY-CImdlKN3.js} +1 -1
- package/dist/assets/{layout-0rzel0v_.js → layout-DQl4aw09.js} +1 -1
- package/dist/assets/{linear-sktN0tB6.js → linear-CLD3aPlK.js} +1 -1
- package/dist/assets/mermaid.core-CN35dvKJ.js +7 -0
- package/dist/assets/{min-rspGOkz_.js → min-_D86henJ.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-BsVzZzTz.js → mindmap-definition-QFDTVHPH-2JVcu1nk.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-CsoWsw4f.js → pieDiagram-DEJITSTG-Dik-m5u7.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-CTMKuCAI.js → quadrantDiagram-34T5L4WZ-Bm5l40Jq.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-C67256te.js → requirementDiagram-MS252O5E-Ck9w5rTj.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-X8Y7KnW0.js → sankeyDiagram-XADWPNL6-BbGVCFEf.js} +1 -1
- package/dist/assets/seqResourceLoaders-Cd_TAXTb.js +2 -0
- package/dist/assets/{seqResourceLoaders-B5Zy_CC_.css → seqResourceLoaders-CxuD1CYl.css} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-Cils0TAp.js → sequenceDiagram-FGHM5R23-vjvEdksk.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-DFnohvpG.js → stateDiagram-FHFEXIEX-DTqMoiB2.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BTX_4o81.js → stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-DrIYFrzZ.js → timeline-definition-GMOUNBTQ-Cw8O-52d.js} +1 -1
- package/dist/assets/{vendor-antd-D5HmSDSg.js → vendor-antd-DI7JL-mE.js} +1 -1
- package/dist/assets/{vendor-codemirror-C5yuj7ze.js → vendor-codemirror-B9c49dtM.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-BcEooTvb.js → vendor-mdxeditor-B6cpBtIE.js} +2 -2
- package/dist/assets/{vendor-qrcode-d9LCn0-9.js → vendor-qrcode-Dn90-s-u.js} +1 -1
- package/dist/assets/{vendor-virtuoso-K9m4MN6a.js → vendor-virtuoso-CpdSKD_6.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-lRqBYTr9.js → vennDiagram-DHZGUBPP-DCuqymb7.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-e8YfxpOe.js → wardley-RL74JXVD-BJ20wJUU.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Bxrg774h.js → wardleyDiagram-NUSXRM2D-Vz6US3qt.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-DWhZqhq8.js → xychartDiagram-5P7HB3ND-CzkLKrmi.js} +1 -1
- package/dist/index.html +4 -4
- package/findcc.js +4 -3
- package/package.json +2 -1
- package/server/i18n.js +21 -141
- package/server/interceptor.js +211 -403
- package/server/lib/async-write-queue.js +12 -1
- package/server/lib/delta-reconstructor.js +3 -3
- package/server/lib/error-report.js +45 -0
- package/server/lib/im-log-watcher.js +68 -8
- package/server/lib/interceptor-core.js +19 -174
- package/server/lib/log-management.js +214 -247
- package/server/lib/log-stream.js +59 -108
- package/server/lib/log-watcher.js +34 -272
- package/server/lib/log-zip.js +148 -0
- package/server/lib/session-boundary.js +266 -0
- package/server/lib/session-pin-store.js +10 -19
- package/server/lib/stats-worker.js +138 -267
- package/server/lib/user-prompt-extract.js +250 -0
- package/server/lib/v2/adapter.js +1059 -0
- package/server/lib/v2/blob-store.js +44 -0
- package/server/lib/v2/conversation-store.js +217 -0
- package/server/lib/v2/convert-manager.js +79 -0
- package/server/lib/v2/convert-worker.js +42 -0
- package/server/lib/v2/convert.js +409 -0
- package/server/lib/v2/identity.js +204 -0
- package/server/lib/v2/journal.js +62 -0
- package/server/lib/v2/layout.js +167 -0
- package/server/lib/v2/live-feed.js +451 -0
- package/server/lib/v2/migrate-prompt.js +54 -0
- package/server/lib/v2/replay.js +191 -0
- package/server/lib/v2/session-select.js +222 -0
- package/server/lib/v2/singleflight.js +104 -0
- package/server/lib/v2/v2-writer.js +513 -0
- package/server/lib/v2/verify.js +233 -0
- package/server/routes/events.js +30 -130
- package/server/routes/files-fs.js +4 -2
- package/server/routes/im.js +11 -2
- package/server/routes/logs.js +194 -152
- package/server/routes/preferences.js +0 -3
- package/server/routes/project-meta.js +2 -3
- package/server/routes/session-pin.js +7 -8
- package/server/routes/workspaces.js +44 -9
- package/server/server.js +47 -65
- package/server/workspace-registry.js +24 -4
- package/dist/assets/App-VTo4NVdo.js +0 -2
- package/dist/assets/Mobile-CcmYvMm_.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BXnyUgsj.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-BXnyUgsj.js +0 -1
- package/dist/assets/clone-md-Mc1-T.js +0 -1
- package/dist/assets/index-DCo650PG.js +0 -2
- package/dist/assets/mermaid.core-C2Parj1q.js +0 -7
- package/dist/assets/seqResourceLoaders-BVozq68i.js +0 -2
- package/server/lib/instance-registry.js +0 -57
- package/server/lib/jsonl-archive.js +0 -180
- package/server/lib/teammate-detect.js +0 -32
- package/server/lib/zip-safety.js +0 -68
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
// Wire Format v2 — write-path orchestrator (docs/refactor/WIRE_FORMAT_V2.md §13).
|
|
2
|
+
//
|
|
3
|
+
// The single entry point the interceptor's writeEntry seam will call in S3.
|
|
4
|
+
// Hard contract: a v2 failure must NEVER disturb the v1 path — every public
|
|
5
|
+
// method is fully caught and routed through reportSwallowed (CLAUDE.md rule);
|
|
6
|
+
// the caller does not need its own try/catch.
|
|
7
|
+
//
|
|
8
|
+
// Write-order protocol per request (spec §1.3):
|
|
9
|
+
// blob (sync, fsync barrier) → conversation event lines → journal req line.
|
|
10
|
+
// The HARD guarantee is the blob half: a journal line can never reference a
|
|
11
|
+
// missing blob (blobs are durable before the line is even enqueued). The conv
|
|
12
|
+
// half is best-effort batch grouping — AsyncWriteQueue._drain groups by path at
|
|
13
|
+
// first-enqueue position, so when several requests flush in one batch (e.g. the
|
|
14
|
+
// cold-start hold queue), a later request's journal line can land before its
|
|
15
|
+
// own conv line. The read side tolerates a missing conv tail by design
|
|
16
|
+
// (spec §14, pendingTail-style retry), so this is defense-in-depth, not a
|
|
17
|
+
// correctness dependency.
|
|
18
|
+
|
|
19
|
+
import { statfsSync, existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { join, basename } from 'node:path';
|
|
21
|
+
import { AsyncWriteQueue } from '../async-write-queue.js';
|
|
22
|
+
import { reportSwallowed } from '../error-report.js';
|
|
23
|
+
import { ensureSessionDirSync, compactLocalTs14, sanitizePathComponent } from './layout.js';
|
|
24
|
+
import { resolveSessionDirName, latestMainSession } from './session-select.js';
|
|
25
|
+
import { BlobStore } from './blob-store.js';
|
|
26
|
+
import { Journal } from './journal.js';
|
|
27
|
+
import { ConversationStore } from './conversation-store.js';
|
|
28
|
+
import { parseUserId, classifyKind, ConvResolver } from './identity.js';
|
|
29
|
+
import { extractUserTexts, flattenPromptText, isSuggestionMode, readPromptsHead } from '../user-prompt-extract.js';
|
|
30
|
+
|
|
31
|
+
// Below this many free bytes on the log volume, v2 skips writing and reports
|
|
32
|
+
// once — logging must not be the thing that fills the disk (plan risk #9).
|
|
33
|
+
const MIN_FREE_BYTES = 1024 * 1024 * 1024; // 1GB
|
|
34
|
+
|
|
35
|
+
// Hard ceiling on distinct prompts recorded per session in prompts.jsonl —
|
|
36
|
+
// keeps both the in-memory dedup set and the side file bounded for extreme
|
|
37
|
+
// thousand-turn sessions (the read side additionally caps at a byte budget).
|
|
38
|
+
const PROMPTS_MAX_PER_SESSION = 2000;
|
|
39
|
+
|
|
40
|
+
export class V2Writer {
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} opts
|
|
43
|
+
* @param {string} opts.logDir - LOG_DIR root
|
|
44
|
+
* @param {string|Function} opts.project - project directory name, or a getter —
|
|
45
|
+
* workspace mode rebinds the interceptor's _projectName at runtime; a falsy
|
|
46
|
+
* resolved project makes ingest a no-op (mirrors v1's empty-LOG_FILE no-op)
|
|
47
|
+
* @param {object|null} [opts.leader] - teammate processes: {agentName, teamName, parentSessionId}
|
|
48
|
+
* @param {boolean} [opts.enabled] - always on since 1.7.0; `enabled: false`
|
|
49
|
+
* is honored only as a test seam (fixtures asserting "nothing written")
|
|
50
|
+
* @param {AsyncWriteQueue} [opts.queue] - injected queue (tests); defaults to a
|
|
51
|
+
* dedicated instance so v2 volume never head-of-line-blocks v1's queue.
|
|
52
|
+
* @param {number} [opts.minFreeBytes] - disk guard threshold (tests)
|
|
53
|
+
* @param {Function} [opts.statfs] - injected statfsSync (tests)
|
|
54
|
+
*/
|
|
55
|
+
constructor(opts = {}) {
|
|
56
|
+
this._logDir = opts.logDir || '';
|
|
57
|
+
this._projectFn = typeof opts.project === 'function'
|
|
58
|
+
? opts.project
|
|
59
|
+
: () => (opts.project || '');
|
|
60
|
+
this._leader = opts.leader || null;
|
|
61
|
+
// 1.7.0: always on in production; opts.enabled === false is honored only
|
|
62
|
+
// as a test seam (fixtures that must assert "nothing written").
|
|
63
|
+
this._enabled = opts.enabled === false ? false : true;
|
|
64
|
+
this._queue = opts.queue || new AsyncWriteQueue(''); // paths are always explicit
|
|
65
|
+
this._minFreeBytes = typeof opts.minFreeBytes === 'number' ? opts.minFreeBytes : MIN_FREE_BYTES;
|
|
66
|
+
this._statfs = opts.statfs || statfsSync;
|
|
67
|
+
// Offline-converter seams (S8): write under a sibling staging dir name,
|
|
68
|
+
// stamp extra meta fields (e.g. {origin:'convert'}), and use exact
|
|
69
|
+
// conversation judgement fingerprints (byte-grade golden gate). Live
|
|
70
|
+
// writers pass none of these.
|
|
71
|
+
this._sessionsDirName = opts.sessionsDirName || 'sessions';
|
|
72
|
+
this._metaExtra = opts.metaExtra || null;
|
|
73
|
+
this._exactConvFps = !!opts.exactConvFps;
|
|
74
|
+
// S6b live feed: called with the session dir after every ingest so the
|
|
75
|
+
// in-process live cursor can read the fresh appends with zero fs-watch
|
|
76
|
+
// latency. Purely a nudge — the feed's data source stays the files.
|
|
77
|
+
this._onActivity = typeof opts.onActivity === 'function' ? opts.onActivity : null;
|
|
78
|
+
this._sessions = new Map(); // sessionId → {paths, blobs, journal, convs, resolver}
|
|
79
|
+
this._currentSid = null; // last successfully resolved session (fallback routing §8.3)
|
|
80
|
+
this._pendingNoSid = []; // requests seen before any sid (cold-start heartbeats).
|
|
81
|
+
// Contract with the caller: originalMessages held here must be a reference
|
|
82
|
+
// the caller never MUTATES (the interceptor's original array is only ever
|
|
83
|
+
// REASSIGNED away from body.messages, never mutated in place — safe).
|
|
84
|
+
this._lateHandles = new Map(); // rid → handle for held requests whose completion arrives after the flush
|
|
85
|
+
this._diskGuardTripped = false;
|
|
86
|
+
this._continuedSeen = false; // P2: wire-level `claude -c` continuation marker
|
|
87
|
+
// `-c` folder adoption (Claude 2.1.210 hands a fresh wire session_id on every
|
|
88
|
+
// continue, so a new folder would otherwise be minted each time). Set from
|
|
89
|
+
// the launch flags via setContinuationMode(); adoption fires at most once per
|
|
90
|
+
// process (the first main request that proves a continuation on the wire).
|
|
91
|
+
this._continuationLaunch = false;
|
|
92
|
+
this._forkSession = false;
|
|
93
|
+
this._resumeSession = false;
|
|
94
|
+
this._adopted = false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** P2: true once any session's FIRST main wire already carried assistant
|
|
98
|
+
* turns — the wire-level signature of a continued (-c/-r) conversation. */
|
|
99
|
+
sawContinuedSession() { return this._continuedSeen; }
|
|
100
|
+
|
|
101
|
+
/** Launch flags for `-c` folder adoption (set by the interceptor before any
|
|
102
|
+
* request flows). `continued` = a pre-request continuation signal
|
|
103
|
+
* (CCV_CLAUDE_CONTINUE / workspace launcher); `fork` = `--fork-session`
|
|
104
|
+
* (user wants a NEW session — never adopt); `resume` = explicit `-r`/
|
|
105
|
+
* `--resume` (user-chosen target session — adoption would misroute it to
|
|
106
|
+
* the LATEST main session, so it keeps its own folder). */
|
|
107
|
+
setContinuationMode({ continued, fork, resume } = {}) {
|
|
108
|
+
this._continuationLaunch = !!continued;
|
|
109
|
+
this._forkSession = !!fork;
|
|
110
|
+
this._resumeSession = !!resume;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 1.7.0: v2 is the only format — the writer is always on. The getter is kept
|
|
114
|
+
// because read-side helpers key off it; the sole write inhibitor left is the
|
|
115
|
+
// in-band disk guard.
|
|
116
|
+
get enabled() { return this._enabled; }
|
|
117
|
+
|
|
118
|
+
/** Late wiring seam: the live feed is constructed by server.js AFTER this
|
|
119
|
+
* writer exists (module init order), so the nudge callback arrives here. */
|
|
120
|
+
setOnActivity(fn) { this._onActivity = typeof fn === 'function' ? fn : null; }
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* If this request should adopt the previous main session's folder — a `-c`
|
|
124
|
+
* continuation launch that Claude handed a FRESH wire session_id — return
|
|
125
|
+
* `{ dirName, identityUUID }` of the folder to reuse, else null. Decided at
|
|
126
|
+
* most once per process, before the folder is created. All conditions must
|
|
127
|
+
* hold: continuation launch via `-c`/`--continue` ONLY (not `--fork-session`,
|
|
128
|
+
* not an explicit `-r`/`--resume` — both keep their own folder); main
|
|
129
|
+
* (non-leader) writer; not already adopted; the wire sid has no folder of its
|
|
130
|
+
* own (never hijack a same-UUID restart); the wire actually replays assistant
|
|
131
|
+
* history (excludes the pty-manager `-c`-stripped retry and stray
|
|
132
|
+
* history-less requests); and a previous main session exists.
|
|
133
|
+
* @returns {{dirName:string, identityUUID:string}|null}
|
|
134
|
+
*/
|
|
135
|
+
_resolveAdoption(sid, project, msgs, entry) {
|
|
136
|
+
if (this._adopted || this._leader) return null;
|
|
137
|
+
if (!this._continuationLaunch || this._forkSession || this._resumeSession) return null;
|
|
138
|
+
if (this._sessions.has(sid)) return null;
|
|
139
|
+
const m = Array.isArray(msgs) ? msgs
|
|
140
|
+
: (entry && entry.body && Array.isArray(entry.body.messages) ? entry.body.messages : null);
|
|
141
|
+
if (!m || m.length <= 1 || !m.some((x) => x && x.role === 'assistant')) return null;
|
|
142
|
+
const projectDir = join(this._logDir, sanitizePathComponent(project));
|
|
143
|
+
if (resolveSessionDirName(projectDir, sid, this._sessionsDirName)) return null; // same-UUID restart
|
|
144
|
+
const prev = latestMainSession(projectDir);
|
|
145
|
+
if (!prev || !prev.sessionId) return null;
|
|
146
|
+
return { dirName: basename(prev.dir), identityUUID: prev.sessionId };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
_session(sessionId, userIdRaw, encoding, project, startTsIso, adoptTarget = null) {
|
|
150
|
+
let s = this._sessions.get(sessionId);
|
|
151
|
+
if (s) return s; // hot path: map hit is O(1), the scan below only runs on miss
|
|
152
|
+
|
|
153
|
+
// Task C: the dir name carries a creation-time prefix `<ts>_<uuid>`. On a
|
|
154
|
+
// map MISS (cold start / restart / `-c` re-attach) the same session must
|
|
155
|
+
// REUSE its existing dir, not mint a `<now>_<uuid>` sibling (which would
|
|
156
|
+
// split the conversation + reset seq + flip _seqEpoch). Scan for an existing
|
|
157
|
+
// dir by UUID; only when none exists create a new ts-prefixed one. The
|
|
158
|
+
// identity written into meta.sessionId / journal sentinel stays the UUID.
|
|
159
|
+
const startTs = startTsIso || new Date().toISOString();
|
|
160
|
+
const meta = {
|
|
161
|
+
pid: process.pid,
|
|
162
|
+
startTs,
|
|
163
|
+
...(userIdRaw && { userIdRaw }),
|
|
164
|
+
...(encoding && { userIdEncoding: encoding }),
|
|
165
|
+
...(this._leader && { leader: this._leader }),
|
|
166
|
+
...(this._metaExtra || {}),
|
|
167
|
+
};
|
|
168
|
+
const projectDir = join(this._logDir, sanitizePathComponent(project));
|
|
169
|
+
// Identity (meta.sessionId + journal sentinel) — normally the wire UUID; on
|
|
170
|
+
// `-c` adoption it stays the ADOPTED folder's UUID so the frontend session
|
|
171
|
+
// identity (`_seqEpoch = v2:<meta.sessionId>`) and all reuse machinery see
|
|
172
|
+
// one continuous session. meta.json/sentinel are first-write-wins, so writing
|
|
173
|
+
// into the existing adopted folder never overwrites its identity.
|
|
174
|
+
let identityId = sessionId;
|
|
175
|
+
let dirName;
|
|
176
|
+
if (adoptTarget) {
|
|
177
|
+
dirName = adoptTarget.dirName;
|
|
178
|
+
identityId = adoptTarget.identityUUID;
|
|
179
|
+
} else {
|
|
180
|
+
dirName = resolveSessionDirName(projectDir, sessionId, this._sessionsDirName);
|
|
181
|
+
if (!dirName) {
|
|
182
|
+
const ts = compactLocalTs14(startTs) || compactLocalTs14(new Date().toISOString());
|
|
183
|
+
dirName = `${ts}_${sessionId}`;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const paths = ensureSessionDirSync(this._logDir, project, identityId, meta, this._sessionsDirName, dirName);
|
|
187
|
+
s = {
|
|
188
|
+
paths,
|
|
189
|
+
blobs: new BlobStore(paths),
|
|
190
|
+
journal: new Journal(paths, this._queue),
|
|
191
|
+
convs: new ConversationStore(paths, this._queue, { exactFps: this._exactConvFps }),
|
|
192
|
+
resolver: new ConvResolver(),
|
|
193
|
+
};
|
|
194
|
+
this._sessions.set(sessionId, s);
|
|
195
|
+
return s;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
_diskOk() {
|
|
199
|
+
if (this._diskGuardTripped) return false;
|
|
200
|
+
try {
|
|
201
|
+
const st = this._statfs(this._logDir);
|
|
202
|
+
const free = Number(st.bavail) * Number(st.bsize);
|
|
203
|
+
if (free < this._minFreeBytes) {
|
|
204
|
+
this._diskGuardTripped = true;
|
|
205
|
+
reportSwallowed('v2-write.disk-guard', new Error(`free ${free}B < ${this._minFreeBytes}B — v2 writes disabled for this process`));
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
return true;
|
|
209
|
+
} catch (err) {
|
|
210
|
+
// statfs failing is not a reason to stop logging — warn once, keep writing.
|
|
211
|
+
reportSwallowed('v2-write.statfs', err);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Request-initiation ingest. MUST be called in the interceptor's synchronous
|
|
218
|
+
* initiation segment (same place v1 assigns _seq), with the ORIGINAL messages
|
|
219
|
+
* array captured BEFORE the v1 delta path mutates body.messages in place.
|
|
220
|
+
*
|
|
221
|
+
* @param {object} entry - the fully materialized v1 requestEntry
|
|
222
|
+
* @param {Array|null} originalMessages - pre-mutation body.messages reference
|
|
223
|
+
* @returns {{sid:string, seq:number}|null} handle for ingestCompletion, or
|
|
224
|
+
* null when disabled/failed (caller passes it back verbatim either way).
|
|
225
|
+
*/
|
|
226
|
+
ingestRequest(entry, originalMessages) {
|
|
227
|
+
if (!this._enabled || !entry) return null;
|
|
228
|
+
try {
|
|
229
|
+
const project = this._projectFn();
|
|
230
|
+
if (!project) return null; // workspace not selected yet — mirrors v1's empty-LOG_FILE no-op
|
|
231
|
+
if (!this._diskOk()) return null;
|
|
232
|
+
|
|
233
|
+
const parsed = parseUserId(entry.body && entry.body.metadata && entry.body.metadata.user_id);
|
|
234
|
+
let sid;
|
|
235
|
+
if (parsed) {
|
|
236
|
+
sid = parsed.sessionId;
|
|
237
|
+
this._currentSid = sid;
|
|
238
|
+
} else if (this._currentSid) {
|
|
239
|
+
sid = this._currentSid; // §8.3 fallback: route metadata-less requests to the active session
|
|
240
|
+
} else {
|
|
241
|
+
// Cold start before any sid-bearing request: hold in memory; flushed by
|
|
242
|
+
// the first resolved request. Bounded to avoid growing forever on a
|
|
243
|
+
// process that only ever sees metadata-less traffic.
|
|
244
|
+
if (this._pendingNoSid.length < 64) {
|
|
245
|
+
// rid captured NOW: the v1 seam deletes entry.requestId at completion,
|
|
246
|
+
// which can happen before the hold queue flushes (review P3-4 — the
|
|
247
|
+
// held request would otherwise never get its done line).
|
|
248
|
+
this._pendingNoSid.push({ entry, originalMessages, rid: entry.requestId || null });
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
sid = `noid-${process.pid}-${Date.now()}`;
|
|
252
|
+
this._currentSid = sid;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// `-c` folder adoption: when this launch is a real continuation but Claude
|
|
256
|
+
// handed a fresh wire session_id, route the writes into the previous main
|
|
257
|
+
// session's folder instead of minting a new (blank) one. Resolved once,
|
|
258
|
+
// before the folder is created; null on every non-adoption path.
|
|
259
|
+
let adoptTarget = null;
|
|
260
|
+
if (parsed) {
|
|
261
|
+
adoptTarget = this._resolveAdoption(sid, project, originalMessages, entry);
|
|
262
|
+
if (adoptTarget) this._adopted = true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// entry.timestamp = this session's first-request time → meta.startTs +
|
|
266
|
+
// the dir-name ts prefix (task C). First-write-wins: meta is only written
|
|
267
|
+
// when the dir is first created, so this is the session's creation time
|
|
268
|
+
// (live ≈ now; convert = the historical first-entry ts).
|
|
269
|
+
const s = this._session(sid, parsed && entry.body.metadata.user_id, parsed && parsed.encoding, project, entry.timestamp, adoptTarget);
|
|
270
|
+
|
|
271
|
+
// Flush requests that arrived before the first sid (they belong here).
|
|
272
|
+
// Their handles are parked in _lateHandles so a completion that arrives
|
|
273
|
+
// after the flush still gets its done line (folded by rid).
|
|
274
|
+
if (this._pendingNoSid.length > 0) {
|
|
275
|
+
const pending = this._pendingNoSid.splice(0);
|
|
276
|
+
for (const p of pending) {
|
|
277
|
+
const pseq = this._ingestInto(s, sid, p.entry, p.originalMessages);
|
|
278
|
+
const prid = p.rid; // captured at hold time — the seam may have deleted entry.requestId by now
|
|
279
|
+
if (pseq != null && prid) {
|
|
280
|
+
this._lateHandles.set(prid, { s, sid, seq: pseq, rid: prid });
|
|
281
|
+
if (this._lateHandles.size > 64) {
|
|
282
|
+
this._lateHandles.delete(this._lateHandles.keys().next().value);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const seq = this._ingestInto(s, sid, entry, originalMessages);
|
|
289
|
+
if (seq != null && this._onActivity) {
|
|
290
|
+
try { this._onActivity(s.paths.dir); } catch { /* nudge only — never disturb the write */ }
|
|
291
|
+
}
|
|
292
|
+
// The handle carries the RESOLVED session object, not just the sid:
|
|
293
|
+
// resetSessions() (workspace switch) clears the _sessions map, and a
|
|
294
|
+
// completion arriving after the switch must still land its done/response
|
|
295
|
+
// lines in the session the request belonged to (review P2: a map lookup
|
|
296
|
+
// at completion time silently dropped them).
|
|
297
|
+
return seq == null ? null : { s, sid, seq, rid: entry.requestId || `${seq}` };
|
|
298
|
+
} catch (err) {
|
|
299
|
+
reportSwallowed('v2-write.ingestRequest', err);
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
_ingestInto(s, sid, entry, originalMessages) {
|
|
305
|
+
const kind = classifyKind(entry);
|
|
306
|
+
const seq = s.journal.nextSeq();
|
|
307
|
+
const rid = entry.requestId || `${seq}`;
|
|
308
|
+
const msgs = Array.isArray(originalMessages) ? originalMessages
|
|
309
|
+
: (entry.body && Array.isArray(entry.body.messages) ? entry.body.messages : null);
|
|
310
|
+
|
|
311
|
+
// P2 wire-level continuation detection: the very first main wire of a
|
|
312
|
+
// session that already carries assistant turns can only be `claude -c`
|
|
313
|
+
// (or -r) resuming an older conversation — a fresh one starts with a
|
|
314
|
+
// single user message. Used by the migrate prompt's `continued` flag.
|
|
315
|
+
if (!this._continuedSeen && kind === 'main' && !s._sawMain && msgs && msgs.length > 1
|
|
316
|
+
&& msgs.some((m) => m && m.role === 'assistant')) {
|
|
317
|
+
this._continuedSeen = true;
|
|
318
|
+
}
|
|
319
|
+
if (kind === 'main') s._sawMain = true;
|
|
320
|
+
|
|
321
|
+
// 1. Blobs (sync + fsync — the durability barrier).
|
|
322
|
+
const toolsRef = s.blobs.put(entry.body && entry.body.tools);
|
|
323
|
+
const sysRef = s.blobs.put(entry.body && entry.body.system);
|
|
324
|
+
|
|
325
|
+
// 2. Conversation event (heartbeats carry no conversation).
|
|
326
|
+
let convKey = null;
|
|
327
|
+
let convResult = null;
|
|
328
|
+
if (kind !== 'heartbeat' && msgs) {
|
|
329
|
+
if (kind === 'main' || kind === 'teammate') convKey = 'main';
|
|
330
|
+
else if (kind === 'sub') convKey = s.resolver.resolveSub(msgs).convKey;
|
|
331
|
+
else convKey = 'misc'; // countTokens & friends: keep wire fidelity under misc
|
|
332
|
+
convResult = s.convs.ingest(convKey, msgs, { seq, rid });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 3. Journal req line — LAST, so it never references missing content.
|
|
336
|
+
s.journal.writeReq({
|
|
337
|
+
seq,
|
|
338
|
+
rid,
|
|
339
|
+
ts: entry.timestamp,
|
|
340
|
+
kind,
|
|
341
|
+
...(convKey && { conv: convKey }),
|
|
342
|
+
...(convResult && { epoch: convResult.epoch }),
|
|
343
|
+
url: entry.url,
|
|
344
|
+
method: entry.method,
|
|
345
|
+
...(entry.body && entry.body.model && { model: entry.body.model }),
|
|
346
|
+
...(entry.isStream && { isStream: true }),
|
|
347
|
+
...(entry.headers && { headers: entry.headers }),
|
|
348
|
+
...((toolsRef || sysRef) && { blobs: { ...(toolsRef && { tools: toolsRef }), ...(sysRef && { sys: sysRef }) } }),
|
|
349
|
+
...(convResult && { msgFrom: convResult.msgFrom, msgTo: convResult.msgTo }),
|
|
350
|
+
...(convResult && convResult.evt && { evt: convResult.evt }),
|
|
351
|
+
...(convResult && convResult.boundary && { boundary: convResult.boundary }),
|
|
352
|
+
...(entry.proxyProfile && { proxy: { profile: entry.proxyProfile, ...(entry.proxyUrl && { url: entry.proxyUrl }) } }),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// 4. prompts.jsonl display cache — strictly AFTER the journal line (a
|
|
356
|
+
// prompts failure must never cost the request its journal record) and
|
|
357
|
+
// fully caught on its own. Main conversation only; the converter drives
|
|
358
|
+
// this same path, so migrated sessions get the cache for free.
|
|
359
|
+
if (convKey === 'main' && convResult && msgs) {
|
|
360
|
+
try { this._appendPrompts(s, seq, msgs, convResult); }
|
|
361
|
+
catch (err) { reportSwallowed('v2-write.prompts', err); }
|
|
362
|
+
}
|
|
363
|
+
return seq;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Append newly-seen user prompts of the main conversation to the session's
|
|
368
|
+
* `prompts.jsonl` ({seq, texts} lines). Idempotent across process restarts:
|
|
369
|
+
* the dedup set is seeded once from the existing file (bounded head read),
|
|
370
|
+
* so a resume/`-c` snapshot replaying the full history appends nothing new.
|
|
371
|
+
*/
|
|
372
|
+
_appendPrompts(s, seq, msgs, convResult) {
|
|
373
|
+
// snapshot (msgFrom=0, full wire), append (new tail) — and replace-tail:
|
|
374
|
+
// a suggestion-mode probe replaced by the REAL next user prompt arrives as
|
|
375
|
+
// a same-length tail swap, so skipping ctl entirely would lose exactly
|
|
376
|
+
// those prompts.
|
|
377
|
+
const covered = convResult.evt === 'append' || convResult.evt === 'snapshot'
|
|
378
|
+
|| (convResult.evt === 'ctl' && convResult.ctl === 'replace-tail');
|
|
379
|
+
if (!covered) return;
|
|
380
|
+
if (isSuggestionMode(msgs)) return; // next-input probes are not user prompts
|
|
381
|
+
if (!s._promptSeen) s._promptSeen = new Set(readPromptsHead(s.paths.promptsPath));
|
|
382
|
+
const slice = msgs.slice(convResult.msgFrom);
|
|
383
|
+
const texts = [];
|
|
384
|
+
for (const text of extractUserTexts(slice)) {
|
|
385
|
+
if (s._promptSeen.size >= PROMPTS_MAX_PER_SESSION) break;
|
|
386
|
+
const flat = flattenPromptText(text);
|
|
387
|
+
if (!flat || s._promptSeen.has(flat)) continue;
|
|
388
|
+
s._promptSeen.add(flat);
|
|
389
|
+
texts.push(flat);
|
|
390
|
+
}
|
|
391
|
+
if (texts.length > 0) {
|
|
392
|
+
this._queue.appendTo(s.paths.promptsPath, JSON.stringify({ seq, texts }) + '\n');
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Completion ingest: responses line + journal done line (+ Agent-spawn
|
|
398
|
+
* registration for future sub-conversation keying, spec §10).
|
|
399
|
+
* @param {{sid:string, seq:number}|null} handle - from ingestRequest
|
|
400
|
+
* @param {object} entry - completed v1 entry (response populated)
|
|
401
|
+
* @param {{doneTs?: string}} [opts] - offline converter: historical done
|
|
402
|
+
* timestamp (entry start + duration); live callers omit it (wall clock).
|
|
403
|
+
*/
|
|
404
|
+
ingestCompletion(handle, entry, opts = {}) {
|
|
405
|
+
if (!this._enabled || !entry) return;
|
|
406
|
+
try {
|
|
407
|
+
// Held cold-start requests returned a null handle at ingestRequest time;
|
|
408
|
+
// recover it by rid (the seam calls us while entry.requestId is intact).
|
|
409
|
+
if (!handle && entry.requestId && this._lateHandles.has(entry.requestId)) {
|
|
410
|
+
handle = this._lateHandles.get(entry.requestId);
|
|
411
|
+
this._lateHandles.delete(entry.requestId);
|
|
412
|
+
}
|
|
413
|
+
if (!handle) return;
|
|
414
|
+
// Prefer the session object carried in the handle (survives
|
|
415
|
+
// resetSessions); the map lookup is only a legacy fallback.
|
|
416
|
+
const s = handle.s || this._sessions.get(handle.sid);
|
|
417
|
+
if (!s) return;
|
|
418
|
+
|
|
419
|
+
const resp = entry.response || null;
|
|
420
|
+
const respBody = resp && resp.body !== undefined ? resp.body : null;
|
|
421
|
+
if (respBody && typeof respBody === 'object') s.resolver.registerSpawns(respBody);
|
|
422
|
+
|
|
423
|
+
// rid comes from the handle, NOT entry.requestId — the v1 completion path
|
|
424
|
+
// deletes requestId from the entry before writing, and the seam must be
|
|
425
|
+
// free to call us before or after those deletes.
|
|
426
|
+
const rid = handle.rid || `${handle.seq}`;
|
|
427
|
+
this._queue.appendTo(
|
|
428
|
+
s.paths.responsesPath,
|
|
429
|
+
JSON.stringify({
|
|
430
|
+
seq: handle.seq,
|
|
431
|
+
rid,
|
|
432
|
+
body: respBody,
|
|
433
|
+
...(resp && resp.headers && { headers: resp.headers }),
|
|
434
|
+
}) + '\n'
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
const usage = respBody && respBody.usage ? respBody.usage : null;
|
|
438
|
+
s.journal.writeDone({
|
|
439
|
+
seq: handle.seq,
|
|
440
|
+
rid,
|
|
441
|
+
ts: opts.doneTs || new Date().toISOString(),
|
|
442
|
+
...(typeof entry.duration === 'number' && { dur: entry.duration }),
|
|
443
|
+
status: resp && resp.error ? 'error' : (respBody == null ? 'capture-failed' : 'ok'),
|
|
444
|
+
...(resp && typeof resp.status === 'number' && { http: resp.status }),
|
|
445
|
+
...(usage && {
|
|
446
|
+
usage: {
|
|
447
|
+
...(usage.input_tokens != null && { in: usage.input_tokens }),
|
|
448
|
+
...(usage.output_tokens != null && { out: usage.output_tokens }),
|
|
449
|
+
...(usage.cache_read_input_tokens != null && { cr: usage.cache_read_input_tokens }),
|
|
450
|
+
...(usage.cache_creation_input_tokens != null && { cw: usage.cache_creation_input_tokens }),
|
|
451
|
+
},
|
|
452
|
+
}),
|
|
453
|
+
...(respBody && respBody.stop_reason && { stop: respBody.stop_reason }),
|
|
454
|
+
});
|
|
455
|
+
if (this._onActivity && s.paths) {
|
|
456
|
+
try { this._onActivity(s.paths.dir); } catch { /* nudge only — never disturb the write */ }
|
|
457
|
+
}
|
|
458
|
+
} catch (err) {
|
|
459
|
+
reportSwallowed('v2-write.ingestCompletion', err);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Session directory of the writer's current (fallback-routing) session, or
|
|
464
|
+
* null before the first sid-bearing request. The read side uses this to
|
|
465
|
+
* resolve "the live session" for /events cold loads (S6b). */
|
|
466
|
+
currentSessionDir() {
|
|
467
|
+
if (!this._currentSid) return null;
|
|
468
|
+
const s = this._sessions.get(this._currentSid);
|
|
469
|
+
return s ? s.paths.dir : null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Lifecycle hook (resume / workspace reset): drop in-memory conversation
|
|
473
|
+
* continuity so the next request snapshots fresh. Journal seq keeps counting
|
|
474
|
+
* (same process, same session dirs — seq must stay monotonic per session). */
|
|
475
|
+
resetConversations() {
|
|
476
|
+
try {
|
|
477
|
+
for (const s of this._sessions.values()) {
|
|
478
|
+
s.convs.reset();
|
|
479
|
+
s.resolver.reset();
|
|
480
|
+
}
|
|
481
|
+
this._currentSid = null;
|
|
482
|
+
} catch (err) {
|
|
483
|
+
reportSwallowed('v2-write.reset', err);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Lifecycle hook (workspace switch): drop ALL cached session bindings so the
|
|
488
|
+
* next request re-creates its session dir under the newly resolved project.
|
|
489
|
+
* A sid that persists across the switch gets a fresh dir (and fresh seq)
|
|
490
|
+
* under the new project — different directory, so no seq collision. */
|
|
491
|
+
resetSessions() {
|
|
492
|
+
try {
|
|
493
|
+
this._sessions.clear();
|
|
494
|
+
this._pendingNoSid.length = 0;
|
|
495
|
+
this._lateHandles.clear();
|
|
496
|
+
this._currentSid = null;
|
|
497
|
+
// A second workspace's `-c` must be able to adopt afresh — the previous
|
|
498
|
+
// workspace's adoption doesn't carry over.
|
|
499
|
+
this._adopted = false;
|
|
500
|
+
} catch (err) {
|
|
501
|
+
reportSwallowed('v2-write.reset-sessions', err);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Flush pending queue writes (tests / graceful shutdown). */
|
|
506
|
+
async flush() {
|
|
507
|
+
await this._queue.flush();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async close() {
|
|
511
|
+
await this._queue.close();
|
|
512
|
+
}
|
|
513
|
+
}
|