@tom2012/cc-web 2026.4.19-o → 2026.4.19-p

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 (67) hide show
  1. package/README.md +2 -2
  2. package/backend/dist/adapters/claude-adapter.d.ts +1 -2
  3. package/backend/dist/adapters/claude-adapter.d.ts.map +1 -1
  4. package/backend/dist/adapters/claude-adapter.js +0 -22
  5. package/backend/dist/adapters/claude-adapter.js.map +1 -1
  6. package/backend/dist/adapters/codex-adapter.d.ts +3 -4
  7. package/backend/dist/adapters/codex-adapter.d.ts.map +1 -1
  8. package/backend/dist/adapters/codex-adapter.js +2 -40
  9. package/backend/dist/adapters/codex-adapter.js.map +1 -1
  10. package/backend/dist/adapters/gemini-adapter.d.ts +1 -7
  11. package/backend/dist/adapters/gemini-adapter.d.ts.map +1 -1
  12. package/backend/dist/adapters/gemini-adapter.js +0 -8
  13. package/backend/dist/adapters/gemini-adapter.js.map +1 -1
  14. package/backend/dist/adapters/opencode-adapter.d.ts +1 -2
  15. package/backend/dist/adapters/opencode-adapter.d.ts.map +1 -1
  16. package/backend/dist/adapters/opencode-adapter.js +0 -1
  17. package/backend/dist/adapters/opencode-adapter.js.map +1 -1
  18. package/backend/dist/adapters/qwen-adapter.d.ts +1 -2
  19. package/backend/dist/adapters/qwen-adapter.d.ts.map +1 -1
  20. package/backend/dist/adapters/qwen-adapter.js +0 -1
  21. package/backend/dist/adapters/qwen-adapter.js.map +1 -1
  22. package/backend/dist/adapters/terminal-adapter.d.ts +1 -2
  23. package/backend/dist/adapters/terminal-adapter.d.ts.map +1 -1
  24. package/backend/dist/adapters/terminal-adapter.js +0 -1
  25. package/backend/dist/adapters/terminal-adapter.js.map +1 -1
  26. package/backend/dist/adapters/types.d.ts +1 -3
  27. package/backend/dist/adapters/types.d.ts.map +1 -1
  28. package/backend/dist/config.d.ts +0 -1
  29. package/backend/dist/config.d.ts.map +1 -1
  30. package/backend/dist/config.js +0 -4
  31. package/backend/dist/config.js.map +1 -1
  32. package/backend/dist/index.d.ts.map +1 -1
  33. package/backend/dist/index.js +0 -25
  34. package/backend/dist/index.js.map +1 -1
  35. package/backend/dist/routes/hooks.d.ts.map +1 -1
  36. package/backend/dist/routes/hooks.js +0 -21
  37. package/backend/dist/routes/hooks.js.map +1 -1
  38. package/backend/dist/routes/projects.d.ts.map +1 -1
  39. package/backend/dist/routes/projects.js +2 -208
  40. package/backend/dist/routes/projects.js.map +1 -1
  41. package/backend/dist/session-manager.d.ts +43 -35
  42. package/backend/dist/session-manager.d.ts.map +1 -1
  43. package/backend/dist/session-manager.js +142 -259
  44. package/backend/dist/session-manager.js.map +1 -1
  45. package/frontend/dist/assets/{AssistantMessageContent-DqROC6KU.js → AssistantMessageContent-wJrH2meJ.js} +1 -1
  46. package/frontend/dist/assets/{GraphPreview-BIuzGAbi.js → GraphPreview-Dc0doluU.js} +1 -1
  47. package/frontend/dist/assets/{MobilePage-DlL0D36Y.js → MobilePage-BpP-L6oO.js} +3 -3
  48. package/frontend/dist/assets/{OfficePreview-DURcco1V.js → OfficePreview-CuLXXJtC.js} +2 -2
  49. package/frontend/dist/assets/{PlanPanel-BjNmFaVk.js → PlanPanel-CxCXowAR.js} +1 -1
  50. package/frontend/dist/assets/{ProjectPage-OChorfDC.js → ProjectPage-B2D-WoYj.js} +5 -5
  51. package/frontend/dist/assets/{SettingsPage-BnD3RAIW.js → SettingsPage-BRdp1y0m.js} +1 -1
  52. package/frontend/dist/assets/{SkillHubPage-jgiKwTgm.js → SkillHubPage-jirxSrmL.js} +3 -3
  53. package/frontend/dist/assets/{chevron-down-Bzon7hPT.js → chevron-down-B6rODcyf.js} +1 -1
  54. package/frontend/dist/assets/{chevron-up-CPpzmtnj.js → chevron-up-CVjkTdMA.js} +1 -1
  55. package/frontend/dist/assets/{index-Bs_kzF7I.js → index-BzaacjGy.js} +8 -8
  56. package/frontend/dist/assets/{index-B_-Wdt1O.js → index-DD7aR8Sa.js} +1 -1
  57. package/frontend/dist/assets/index-D_umrsZK.css +1 -0
  58. package/frontend/dist/assets/{index-eYA5tcCV.js → index-Qcpg6uVd.js} +1 -1
  59. package/frontend/dist/assets/{jszip.min-B96GO4qd.js → jszip.min-C_V8wSYG.js} +1 -1
  60. package/frontend/dist/assets/{maximize-2-Dil4Qhky.js → maximize-2-C6Y5UA8p.js} +1 -1
  61. package/frontend/dist/assets/{search-CVR9qZ6f.js → search-BEl0zazi.js} +1 -1
  62. package/frontend/dist/index.html +2 -2
  63. package/package.json +1 -1
  64. package/frontend/dist/assets/ShareViewPage-BmNNrInp.js +0 -1
  65. package/frontend/dist/assets/bot-DiR-1KP3.js +0 -7
  66. package/frontend/dist/assets/index-CdG-i6ZW.css +0 -1
  67. package/frontend/dist/assets/user-D1xnGKon.js +0 -7
@@ -1,22 +1,12 @@
1
1
  /**
2
- * SessionManager — reads conversation history directly from Claude Code's
3
- * native JSONL files at ~/.claude/projects/{encoded-path}/{sessionId}.jsonl
2
+ * SessionManager — reads conversation history directly from the CLI's
3
+ * native JSONL files (e.g. ~/.claude/projects/{encoded-path}/{uuid}.jsonl).
4
4
  *
5
- * No PTY parsing, no ANSI stripping, no heuristics needed.
5
+ * No PTY parsing, no ANSI stripping, no heuristics. The JSONL is the single
6
+ * source of truth; ccweb maintains no separate conversation store.
6
7
  */
7
8
  import { EventEmitter } from 'events';
8
9
  import type { CliTool } from './types';
9
- export interface SessionMessage {
10
- role: 'user' | 'assistant';
11
- content: string;
12
- timestamp: string;
13
- }
14
- export interface Session {
15
- id: string;
16
- projectId: string;
17
- startedAt: string;
18
- messages: SessionMessage[];
19
- }
20
10
  export interface ChatBlockItem {
21
11
  type: 'text' | 'thinking' | 'tool_use' | 'tool_result';
22
12
  content: string;
@@ -46,13 +36,45 @@ declare class SessionManager extends EventEmitter {
46
36
  getAllSemanticStatus(): Record<string, SemanticStatus>;
47
37
  /** Return the JSONL file path currently being tailed for this project. */
48
38
  getJsonlPath(projectId: string): string | null;
49
- /** Return all parsed ChatBlocks from the current session file (for replay on chat_subscribe). */
39
+ /** Return all parsed ChatBlocks from the project's latest JSONL file.
40
+ *
41
+ * Resolution order:
42
+ * 1. Active watcher already has `jsonlPath` discovered via hooks → use it.
43
+ * 2. Watcher exists but hasn't discovered the file yet → lazily scan the
44
+ * adapter's session dir, pick the latest JSONL, cache on the watcher.
45
+ * 3. No watcher (stopped project, user just navigated in) → resolve
46
+ * project config on-the-fly and pick the latest JSONL. Do NOT
47
+ * persist to `this.watchers` because a watcher without a PTY
48
+ * would drift (no hooks, no triggerRead).
49
+ *
50
+ * This indirection exists because chat history has to be loadable without
51
+ * a hook ever firing (e.g. immediately after ccweb restart — triggerRead
52
+ * only runs when Claude Code's hooks call `/api/hooks`).
53
+ */
50
54
  getChatHistory(projectId: string): ChatBlock[];
55
+ /** Parse a JSONL/JSON session file into ChatBlocks with stable ids. */
56
+ private parseJsonlFile;
57
+ /** Find the latest JSONL file for a project (single source of truth for
58
+ * both chat-history HTTP and hook-driven tail paths).
59
+ *
60
+ * Strategy: pick the newest file (by mtime) in the adapter's session dir.
61
+ * For adapters that store sessions in a shared tree (e.g. Codex's
62
+ * date-partitioned dir), use `getSessionFilesForProject` to scope by cwd.
63
+ *
64
+ * Deliberately has NO startedAt/recency filter — historical requirement
65
+ * was for "the file THIS session is writing", but that caused HTTP and
66
+ * WS paths to disagree on which file to read, breaking block-id dedup.
67
+ * Using "newest" works because Claude --continue updates the mtime of
68
+ * the continued file, and any new file it creates has the highest mtime.
69
+ * The caller (triggerRead) handles mid-session file switches by re-
70
+ * checking on each call.
71
+ */
72
+ private findLatestJsonlForProject;
51
73
  registerChatListener(projectId: string, cb: (msg: ChatBlock) => void): void;
52
74
  unregisterChatListener(projectId: string, cb: (msg: ChatBlock) => void): void;
53
- /** Call when a new PTY starts for a project */
75
+ /** Call when a new PTY starts for a project. Registers a watcher so that
76
+ * subsequent hook-driven `triggerRead()` calls can tail the JSONL. */
54
77
  startSession(projectId: string, folderPath: string, cliTool?: CliTool): void;
55
- private pruneOldSessions;
56
78
  /** Stop the session poller for a project (public for cleanup on terminal stop) */
57
79
  stopWatcherForProject(projectId: string): void;
58
80
  private stopWatcher;
@@ -60,33 +82,19 @@ declare class SessionManager extends EventEmitter {
60
82
  * Updates semantic status directly from env var — does NOT read JSONL
61
83
  * (JSONL has not been written yet at this point). */
62
84
  handleHookPreTool(projectId: string, toolName: string): void;
63
- /** Called by hooks route on PostToolUse/Stop.
64
- * Immediately reads any new lines from the JSONL file. */
85
+ /** Called by hooks route on PostToolUse/Stop. Discovers the latest JSONL
86
+ * file if not already cached, detects mid-session file switches (e.g.
87
+ * Claude --continue creating a fresh JSONL), then reads new content and
88
+ * emits block events to chat listeners. */
65
89
  triggerRead(projectId: string): void;
66
90
  /** Called by hooks route on Stop — clears semantic status before reading final text. */
67
91
  clearSemanticStatus(projectId: string): void;
68
- /** Find the newest session file created after startedAt in the tool's session dir */
69
- private findJsonl;
70
92
  /** Read new data from the session file and extract messages */
71
93
  private readNewLines;
72
94
  /** Read whole-file JSON session (Gemini etc.) — re-parse on each trigger, diff against last known state */
73
95
  private readWholeFileSession;
74
96
  /** Incremental JSONL reading (Claude, Codex) */
75
97
  private readJsonlIncremental;
76
- private appendMessages;
77
- /** Overwrite all messages (for whole-file JSON tools that re-parse the entire session) */
78
- private overwriteMessages;
79
- /** Resolve folderPath for a project — from active watcher or project config */
80
- private resolveFolderPath;
81
- /** Read session files from a directory */
82
- private readSessionsFromDir;
83
- listSessions(projectId: string): (Omit<Session, 'messages'> & {
84
- messageCount: number;
85
- isCurrent: boolean;
86
- })[];
87
- /** Validate sessionId to prevent path traversal */
88
- private isValidSessionId;
89
- getSession(projectId: string, sessionId: string): Session | null;
90
98
  }
91
99
  export declare const sessionManager: SessionManager;
92
100
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../src/session-manager.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../src/session-manager.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAKH,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAGtC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAIvC,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,UAAU,GAAG,aAAa,CAAC;IACvD,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,SAAS;IACxB;;;2BAGuB;IACvB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,aAAa,EAAE,CAAC;CACzB;AAWD,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,UAAU,GAAG,aAAa,GAAG,MAAM,CAAC;AAE7E,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,aAAa,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;CACnB;AAeD,cAAM,cAAe,SAAQ,YAAY;IACvC,OAAO,CAAC,QAAQ,CAAiC;IACjD,OAAO,CAAC,aAAa,CAAoD;IACzE,OAAO,CAAC,cAAc,CAAqC;;IAM3D,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI;IAI3D,oBAAoB,IAAI,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC;IAQtD,0EAA0E;IAC1E,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAI9C;;;;;;;;;;;;;;OAcG;IACH,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE;IA0B9C,uEAAuE;IACvE,OAAO,CAAC,cAAc;IA2BtB;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,yBAAyB;IA6BjC,oBAAoB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,GAAG,EAAE,SAAS,KAAK,IAAI,GAAG,IAAI;IAK3E,sBAAsB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,GAAG,EAAE,SAAS,KAAK,IAAI,GAAG,IAAI;IAO7E;2EACuE;IACvE,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO,GAAE,OAAkB,GAAG,IAAI;IAYtF,kFAAkF;IAClF,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAI9C,OAAO,CAAC,WAAW;IAKnB;;0DAEsD;IACtD,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI;IAU5D;;;gDAG4C;IAC5C,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IA2CpC,wFAAwF;IACxF,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAM5C,+DAA+D;IAC/D,OAAO,CAAC,YAAY;IAepB,2GAA2G;IAC3G,OAAO,CAAC,oBAAoB;IAmC5B,gDAAgD;IAChD,OAAO,CAAC,oBAAoB;CAgD7B;AAED,eAAO,MAAM,cAAc,gBAAuB,CAAC"}
@@ -1,9 +1,10 @@
1
1
  "use strict";
2
2
  /**
3
- * SessionManager — reads conversation history directly from Claude Code's
4
- * native JSONL files at ~/.claude/projects/{encoded-path}/{sessionId}.jsonl
3
+ * SessionManager — reads conversation history directly from the CLI's
4
+ * native JSONL files (e.g. ~/.claude/projects/{encoded-path}/{uuid}.jsonl).
5
5
  *
6
- * No PTY parsing, no ANSI stripping, no heuristics needed.
6
+ * No PTY parsing, no ANSI stripping, no heuristics. The JSONL is the single
7
+ * source of truth; ccweb maintains no separate conversation store.
7
8
  */
8
9
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
9
10
  if (k2 === undefined) k2 = k;
@@ -44,7 +45,6 @@ const fs = __importStar(require("fs"));
44
45
  const path = __importStar(require("path"));
45
46
  const crypto = __importStar(require("crypto"));
46
47
  const events_1 = require("events");
47
- const uuid_1 = require("uuid");
48
48
  const config_1 = require("./config");
49
49
  const adapters_1 = require("./adapters");
50
50
  /** Generate a stable 16-hex-char id for a chat block.
@@ -53,22 +53,6 @@ const adapters_1 = require("./adapters");
53
53
  function makeBlockId(jsonlPath, source) {
54
54
  return crypto.createHash('sha1').update(jsonlPath + '\0' + source).digest('hex').slice(0, 16);
55
55
  }
56
- // ── Path helpers ─────────────────────────────────────────────────────────────
57
- const LEGACY_SESSIONS_DIR = path.join(config_1.DATA_DIR, 'sessions');
58
- /** New location: {folderPath}/.ccweb/sessions/ */
59
- function projectSessionsDir(folderPath) {
60
- return (0, config_1.ccwebSessionsDir)(folderPath);
61
- }
62
- function projectSessionFile(folderPath, sessionId) {
63
- return path.join(projectSessionsDir(folderPath), `${sessionId}.json`);
64
- }
65
- /** Legacy location: data/sessions/{projectId}/ */
66
- function legacySessionsDir(projectId) {
67
- return path.join(LEGACY_SESSIONS_DIR, projectId);
68
- }
69
- function legacySessionFile(projectId, sessionId) {
70
- return path.join(legacySessionsDir(projectId), `${sessionId}.json`);
71
- }
72
56
  // ── SessionManager ────────────────────────────────────────────────────────────
73
57
  class SessionManager extends events_1.EventEmitter {
74
58
  constructor() {
@@ -91,20 +75,56 @@ class SessionManager extends events_1.EventEmitter {
91
75
  getJsonlPath(projectId) {
92
76
  return this.watchers.get(projectId)?.jsonlPath ?? null;
93
77
  }
94
- /** Return all parsed ChatBlocks from the current session file (for replay on chat_subscribe). */
78
+ /** Return all parsed ChatBlocks from the project's latest JSONL file.
79
+ *
80
+ * Resolution order:
81
+ * 1. Active watcher already has `jsonlPath` discovered via hooks → use it.
82
+ * 2. Watcher exists but hasn't discovered the file yet → lazily scan the
83
+ * adapter's session dir, pick the latest JSONL, cache on the watcher.
84
+ * 3. No watcher (stopped project, user just navigated in) → resolve
85
+ * project config on-the-fly and pick the latest JSONL. Do NOT
86
+ * persist to `this.watchers` because a watcher without a PTY
87
+ * would drift (no hooks, no triggerRead).
88
+ *
89
+ * This indirection exists because chat history has to be loadable without
90
+ * a hook ever firing (e.g. immediately after ccweb restart — triggerRead
91
+ * only runs when Claude Code's hooks call `/api/hooks`).
92
+ */
95
93
  getChatHistory(projectId) {
96
94
  const state = this.watchers.get(projectId);
97
- if (!state?.jsonlPath)
95
+ if (state) {
96
+ if (!state.jsonlPath) {
97
+ state.jsonlPath = this.findLatestJsonlForProject(state.folderPath, state.cliTool);
98
+ }
99
+ if (!state.jsonlPath)
100
+ return [];
101
+ return this.parseJsonlFile(state.jsonlPath, state.cliTool);
102
+ }
103
+ // No active watcher — fall back to project config for stopped projects
104
+ const project = (0, config_1.getProject)(projectId);
105
+ if (!project) {
106
+ console.warn(`[SessionManager] getChatHistory(${projectId}): project not in registry`);
98
107
  return [];
99
- const adapter = (0, adapters_1.getAdapter)(state.cliTool);
108
+ }
109
+ const cliTool = project.cliTool ?? 'claude';
110
+ const jsonlPath = this.findLatestJsonlForProject(project.folderPath, cliTool);
111
+ if (!jsonlPath) {
112
+ console.warn(`[SessionManager] getChatHistory(${projectId}): no JSONL found for ${project.folderPath} (${cliTool})`);
113
+ return [];
114
+ }
115
+ return this.parseJsonlFile(jsonlPath, cliTool);
116
+ }
117
+ /** Parse a JSONL/JSON session file into ChatBlocks with stable ids. */
118
+ parseJsonlFile(jsonlPath, cliTool) {
119
+ const adapter = (0, adapters_1.getAdapter)(cliTool);
100
120
  try {
101
- const content = fs.readFileSync(state.jsonlPath, 'utf-8');
121
+ const content = fs.readFileSync(jsonlPath, 'utf-8');
102
122
  // Whole-file JSON tools (e.g. Gemini): parse entire file at once
103
123
  if (typeof adapter.parseSessionFile === 'function') {
104
124
  const blocks = adapter.parseSessionFile(content);
105
125
  return blocks.map((b) => ({
106
126
  ...b,
107
- id: b.id ?? makeBlockId(state.jsonlPath, b.timestamp + '|' + JSON.stringify(b.blocks)),
127
+ id: b.id ?? makeBlockId(jsonlPath, b.timestamp + '|' + JSON.stringify(b.blocks)),
108
128
  }));
109
129
  }
110
130
  // JSONL tools (Claude, Codex): parse line by line
@@ -113,7 +133,7 @@ class SessionManager extends events_1.EventEmitter {
113
133
  for (const line of lines) {
114
134
  const block = adapter.parseLineBlocks(line);
115
135
  if (block)
116
- blocks.push({ ...block, id: makeBlockId(state.jsonlPath, line) });
136
+ blocks.push({ ...block, id: makeBlockId(jsonlPath, line) });
117
137
  }
118
138
  return blocks;
119
139
  }
@@ -121,6 +141,53 @@ class SessionManager extends events_1.EventEmitter {
121
141
  return [];
122
142
  }
123
143
  }
144
+ /** Find the latest JSONL file for a project (single source of truth for
145
+ * both chat-history HTTP and hook-driven tail paths).
146
+ *
147
+ * Strategy: pick the newest file (by mtime) in the adapter's session dir.
148
+ * For adapters that store sessions in a shared tree (e.g. Codex's
149
+ * date-partitioned dir), use `getSessionFilesForProject` to scope by cwd.
150
+ *
151
+ * Deliberately has NO startedAt/recency filter — historical requirement
152
+ * was for "the file THIS session is writing", but that caused HTTP and
153
+ * WS paths to disagree on which file to read, breaking block-id dedup.
154
+ * Using "newest" works because Claude --continue updates the mtime of
155
+ * the continued file, and any new file it creates has the highest mtime.
156
+ * The caller (triggerRead) handles mid-session file switches by re-
157
+ * checking on each call.
158
+ */
159
+ findLatestJsonlForProject(folderPath, cliTool) {
160
+ const adapter = (0, adapters_1.getAdapter)(cliTool);
161
+ if (typeof adapter.getSessionFilesForProject === 'function') {
162
+ const files = adapter.getSessionFilesForProject(folderPath);
163
+ if (files.length === 0)
164
+ return null;
165
+ try {
166
+ return files
167
+ .map((f) => ({ f, mtime: fs.statSync(f).mtimeMs }))
168
+ .sort((a, b) => b.mtime - a.mtime)[0].f;
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ }
174
+ const dir = adapter.getSessionDir(folderPath);
175
+ if (!dir || !fs.existsSync(dir))
176
+ return null;
177
+ const ext = typeof adapter.getSessionFileExtension === 'function'
178
+ ? adapter.getSessionFileExtension()
179
+ : '.jsonl';
180
+ try {
181
+ const files = fs.readdirSync(dir)
182
+ .filter((f) => f.endsWith(ext))
183
+ .map((f) => ({ f, mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
184
+ .sort((a, b) => b.mtime - a.mtime);
185
+ return files.length > 0 ? path.join(dir, files[0].f) : null;
186
+ }
187
+ catch {
188
+ return null;
189
+ }
190
+ }
124
191
  registerChatListener(projectId, cb) {
125
192
  if (!this.chatListeners.has(projectId))
126
193
  this.chatListeners.set(projectId, new Set());
@@ -134,51 +201,18 @@ class SessionManager extends events_1.EventEmitter {
134
201
  if (listeners.size === 0)
135
202
  this.chatListeners.delete(projectId);
136
203
  }
137
- /** Call when a new PTY starts for a project */
204
+ /** Call when a new PTY starts for a project. Registers a watcher so that
205
+ * subsequent hook-driven `triggerRead()` calls can tail the JSONL. */
138
206
  startSession(projectId, folderPath, cliTool = 'claude') {
139
- // Stop any previous watcher
140
207
  this.stopWatcher(projectId);
141
- const sessionId = `${Date.now()}-${(0, uuid_1.v4)().slice(0, 8)}`;
142
- const startedAt = Date.now();
143
- // Create our session file in .ccweb/sessions/
144
- fs.mkdirSync(projectSessionsDir(folderPath), { recursive: true });
145
- const session = { id: sessionId, projectId, startedAt: new Date(startedAt).toISOString(), messages: [] };
146
- fs.writeFileSync(projectSessionFile(folderPath, sessionId), JSON.stringify(session, null, 2), 'utf-8');
147
- const state = {
148
- sessionId,
208
+ this.watchers.set(projectId, {
149
209
  folderPath,
150
210
  cliTool,
151
211
  jsonlPath: null,
152
212
  fileOffset: 0,
153
- startedAt,
154
- };
155
- this.watchers.set(projectId, state);
156
- // Prune old sessions (keep latest 20)
157
- this.pruneOldSessions(folderPath, projectId);
158
- console.log(`[SessionManager] Started session ${sessionId} for project ${projectId}`);
159
- }
160
- pruneOldSessions(folderPath, projectId, keep = 20) {
161
- const dirs = [projectSessionsDir(folderPath), legacySessionsDir(projectId)];
162
- for (const dir of dirs) {
163
- if (!fs.existsSync(dir))
164
- continue;
165
- try {
166
- const files = fs.readdirSync(dir)
167
- .filter((f) => f.endsWith('.json'))
168
- .sort(); // session filenames start with timestamp, so sort = chronological
169
- if (files.length <= keep)
170
- continue;
171
- const toDelete = files.slice(0, files.length - keep);
172
- for (const f of toDelete) {
173
- try {
174
- fs.unlinkSync(path.join(dir, f));
175
- }
176
- catch { /**/ }
177
- }
178
- console.log(`[SessionManager] Pruned ${toDelete.length} old sessions in ${dir}`);
179
- }
180
- catch { /**/ }
181
- }
213
+ startedAt: Date.now(),
214
+ });
215
+ console.log(`[SessionManager] Started watcher for project ${projectId}`);
182
216
  }
183
217
  /** Stop the session poller for a project (public for cleanup on terminal stop) */
184
218
  stopWatcherForProject(projectId) {
@@ -200,49 +234,55 @@ class SessionManager extends events_1.EventEmitter {
200
234
  this.semanticStatus.set(projectId, newStatus);
201
235
  this.emit('semantic', { projectId, status: newStatus });
202
236
  }
203
- /** Called by hooks route on PostToolUse/Stop.
204
- * Immediately reads any new lines from the JSONL file. */
237
+ /** Called by hooks route on PostToolUse/Stop. Discovers the latest JSONL
238
+ * file if not already cached, detects mid-session file switches (e.g.
239
+ * Claude --continue creating a fresh JSONL), then reads new content and
240
+ * emits block events to chat listeners. */
205
241
  triggerRead(projectId) {
206
242
  const state = this.watchers.get(projectId);
207
243
  if (!state)
208
244
  return;
209
- // If JSONL not found yet, retry up to 3 times (handles race where hook fires before first write)
245
+ // Re-check latest file each call: shares the single `findLatestJsonlForProject`
246
+ // path with getChatHistory so HTTP and WS paths always agree on which file
247
+ // is authoritative (→ consistent block ids → frontend dedup works).
248
+ const latest = this.findLatestJsonlForProject(state.folderPath, state.cliTool);
249
+ if (latest && latest !== state.jsonlPath) {
250
+ state.jsonlPath = latest;
251
+ state.fileOffset = 0;
252
+ }
210
253
  if (!state.jsonlPath) {
211
- state.jsonlPath = this.findJsonl(state.folderPath, state.startedAt, state.cliTool);
212
- if (!state.jsonlPath) {
213
- // Guard: skip if a retry chain is already running for this project
214
- if (state.retrying)
215
- return;
216
- state.retrying = true;
217
- const delays = [500, 1000, 2000];
218
- const retry = (attempt) => {
219
- setTimeout(() => {
220
- const s = this.watchers.get(projectId);
221
- if (!s)
222
- return;
223
- if (s.jsonlPath) {
224
- s.retrying = false;
225
- return;
226
- }
227
- s.jsonlPath = this.findJsonl(s.folderPath, s.startedAt, s.cliTool);
228
- if (s.jsonlPath) {
229
- s.retrying = false;
230
- s.fileOffset = 0;
231
- this.readNewLines(projectId, s);
232
- }
233
- else if (attempt + 1 < delays.length) {
234
- retry(attempt + 1);
235
- }
236
- else {
237
- s.retrying = false;
238
- console.warn(`[SessionManager] JSONL file not found for project ${projectId} after ${delays.length} retries — chat history unavailable`);
239
- }
240
- }, delays[attempt]);
241
- };
242
- retry(0);
254
+ // Brand-new project or session dir not yet created — retry a few times
255
+ if (state.retrying)
243
256
  return;
244
- }
245
- state.fileOffset = 0;
257
+ state.retrying = true;
258
+ const delays = [500, 1000, 2000];
259
+ const retry = (attempt) => {
260
+ setTimeout(() => {
261
+ const s = this.watchers.get(projectId);
262
+ if (!s)
263
+ return;
264
+ if (s.jsonlPath) {
265
+ s.retrying = false;
266
+ return;
267
+ }
268
+ const later = this.findLatestJsonlForProject(s.folderPath, s.cliTool);
269
+ if (later) {
270
+ s.retrying = false;
271
+ s.jsonlPath = later;
272
+ s.fileOffset = 0;
273
+ this.readNewLines(projectId, s);
274
+ }
275
+ else if (attempt + 1 < delays.length) {
276
+ retry(attempt + 1);
277
+ }
278
+ else {
279
+ s.retrying = false;
280
+ console.warn(`[SessionManager] JSONL file not found for project ${projectId} after ${delays.length} retries — chat history unavailable`);
281
+ }
282
+ }, delays[attempt]);
283
+ };
284
+ retry(0);
285
+ return;
246
286
  }
247
287
  this.readNewLines(projectId, state);
248
288
  }
@@ -253,27 +293,6 @@ class SessionManager extends events_1.EventEmitter {
253
293
  this.semanticStatus.delete(projectId);
254
294
  this.emit('semantic', { projectId, status: null });
255
295
  }
256
- /** Find the newest session file created after startedAt in the tool's session dir */
257
- findJsonl(folderPath, startedAt, cliTool = 'claude') {
258
- const adapter = (0, adapters_1.getAdapter)(cliTool);
259
- const dir = adapter.getSessionDir(folderPath);
260
- if (!dir || !fs.existsSync(dir))
261
- return null;
262
- const ext = typeof adapter.getSessionFileExtension === 'function'
263
- ? adapter.getSessionFileExtension()
264
- : '.jsonl';
265
- try {
266
- const files = fs.readdirSync(dir)
267
- .filter((f) => f.endsWith(ext))
268
- .map((f) => ({ f, mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
269
- .filter(({ mtime }) => mtime >= startedAt - 5000) // 5s grace
270
- .sort((a, b) => b.mtime - a.mtime);
271
- return files.length > 0 ? path.join(dir, files[0].f) : null;
272
- }
273
- catch {
274
- return null;
275
- }
276
- }
277
296
  /** Read new data from the session file and extract messages */
278
297
  readNewLines(projectId, state) {
279
298
  if (!state.jsonlPath)
@@ -298,18 +317,6 @@ class SessionManager extends events_1.EventEmitter {
298
317
  const blocks = adapter.parseSessionFile(content);
299
318
  if (blocks.length === 0)
300
319
  return;
301
- // Extract SessionMessages for our ccweb session file
302
- const newMsgs = [];
303
- for (const block of blocks) {
304
- const text = block.blocks.filter(b => b.type === 'text').map(b => b.content).join('\n').trim();
305
- if (text) {
306
- newMsgs.push({ role: block.role, content: text, timestamp: block.timestamp });
307
- }
308
- }
309
- if (newMsgs.length > 0) {
310
- // Overwrite (not append) since we re-parsed the whole file
311
- this.overwriteMessages(state.folderPath, state.sessionId, newMsgs);
312
- }
313
320
  // Emit latest blocks to chat listeners + update semantic status
314
321
  for (const block of blocks) {
315
322
  const blockWithId = {
@@ -351,17 +358,6 @@ class SessionManager extends events_1.EventEmitter {
351
358
  fs.readSync(fd, buf, 0, toRead, state.fileOffset);
352
359
  state.fileOffset = stat.size;
353
360
  const lines = buf.toString('utf-8').split('\n').filter((l) => l.trim());
354
- let changed = false;
355
- const newMsgs = [];
356
- for (const line of lines) {
357
- const msg = adapter.parseLine(line);
358
- if (msg)
359
- newMsgs.push(msg);
360
- }
361
- if (newMsgs.length > 0) {
362
- this.appendMessages(state.folderPath, state.sessionId, newMsgs);
363
- changed = true;
364
- }
365
361
  // Emit to chat listeners + update semantic status
366
362
  for (const line of lines) {
367
363
  const parsed = adapter.parseLineBlocks(line);
@@ -391,9 +387,6 @@ class SessionManager extends events_1.EventEmitter {
391
387
  }
392
388
  }
393
389
  }
394
- if (changed) {
395
- console.log(`[SessionManager] Updated session ${state.sessionId}`);
396
- }
397
390
  }
398
391
  catch {
399
392
  // file may be temporarily locked or missing — try again next poll
@@ -406,116 +399,6 @@ class SessionManager extends events_1.EventEmitter {
406
399
  catch { /**/ }
407
400
  }
408
401
  }
409
- appendMessages(folderPath, sessionId, msgs) {
410
- const file = projectSessionFile(folderPath, sessionId);
411
- try {
412
- const session = JSON.parse(fs.readFileSync(file, 'utf-8'));
413
- session.messages.push(...msgs);
414
- const tmpPath = file + `.tmp.${process.pid}`;
415
- fs.writeFileSync(tmpPath, JSON.stringify(session, null, 2), 'utf-8');
416
- fs.renameSync(tmpPath, file);
417
- }
418
- catch (err) {
419
- console.error(`[SessionManager] Failed to append messages to session ${sessionId}:`, err);
420
- }
421
- }
422
- /** Overwrite all messages (for whole-file JSON tools that re-parse the entire session) */
423
- overwriteMessages(folderPath, sessionId, msgs) {
424
- const file = projectSessionFile(folderPath, sessionId);
425
- try {
426
- const session = JSON.parse(fs.readFileSync(file, 'utf-8'));
427
- session.messages = msgs;
428
- const tmpPath = file + `.tmp.${process.pid}`;
429
- fs.writeFileSync(tmpPath, JSON.stringify(session, null, 2), 'utf-8');
430
- fs.renameSync(tmpPath, file);
431
- }
432
- catch (err) {
433
- console.error(`[SessionManager] Failed to overwrite messages for session ${sessionId}:`, err);
434
- }
435
- }
436
- // ── Query API ──────────────────────────────────────────────────────────────
437
- /** Resolve folderPath for a project — from active watcher or project config */
438
- resolveFolderPath(projectId) {
439
- const watcher = this.watchers.get(projectId);
440
- if (watcher)
441
- return watcher.folderPath;
442
- const project = (0, config_1.getProject)(projectId);
443
- return project?.folderPath ?? null;
444
- }
445
- /** Read session files from a directory */
446
- readSessionsFromDir(dir, currentId) {
447
- if (!fs.existsSync(dir))
448
- return [];
449
- try {
450
- return fs.readdirSync(dir)
451
- .filter((f) => f.endsWith('.json'))
452
- .map((f) => {
453
- try {
454
- const s = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8'));
455
- return {
456
- id: s.id,
457
- projectId: s.projectId,
458
- startedAt: s.startedAt,
459
- messageCount: s.messages.length,
460
- isCurrent: s.id === currentId,
461
- };
462
- }
463
- catch {
464
- return null;
465
- }
466
- })
467
- .filter((s) => s !== null);
468
- }
469
- catch {
470
- return [];
471
- }
472
- }
473
- listSessions(projectId) {
474
- const currentId = this.watchers.get(projectId)?.sessionId;
475
- const folderPath = this.resolveFolderPath(projectId);
476
- // Collect from .ccweb/sessions/ (primary) and legacy data/sessions/ (fallback)
477
- const results = [];
478
- const seenIds = new Set();
479
- if (folderPath) {
480
- for (const s of this.readSessionsFromDir(projectSessionsDir(folderPath), currentId)) {
481
- results.push(s);
482
- seenIds.add(s.id);
483
- }
484
- }
485
- // Legacy fallback — include sessions not already in .ccweb/
486
- for (const s of this.readSessionsFromDir(legacySessionsDir(projectId), currentId)) {
487
- if (!seenIds.has(s.id)) {
488
- results.push(s);
489
- }
490
- }
491
- return results.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
492
- }
493
- /** Validate sessionId to prevent path traversal */
494
- isValidSessionId(sessionId) {
495
- return /^[\w-]+$/.test(sessionId) && !sessionId.includes('..');
496
- }
497
- getSession(projectId, sessionId) {
498
- if (!this.isValidSessionId(sessionId))
499
- return null;
500
- const folderPath = this.resolveFolderPath(projectId);
501
- // Try .ccweb/ first
502
- if (folderPath) {
503
- const file = projectSessionFile(folderPath, sessionId);
504
- try {
505
- if (fs.existsSync(file)) {
506
- return JSON.parse(fs.readFileSync(file, 'utf-8'));
507
- }
508
- }
509
- catch { /* fall through */ }
510
- }
511
- // Legacy fallback
512
- try {
513
- return JSON.parse(fs.readFileSync(legacySessionFile(projectId, sessionId), 'utf-8'));
514
- }
515
- catch {
516
- return null;
517
- }
518
- }
519
402
  }
520
403
  exports.sessionManager = new SessionManager();
521
404
  //# sourceMappingURL=session-manager.js.map