cc-viewer 1.7.1 → 1.7.2

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.
@@ -75,9 +75,13 @@ const SCAN_CHUNK = 64 * 1024;
75
75
  * @param {string} dir - absolute session dir
76
76
  * @param {number} budget - max bytes to read before giving up
77
77
  * @param {(line:string) => boolean} onLine
78
+ * @param {boolean} [ioErrorResult=false] - returned when the journal EXISTS
79
+ * but reading it throws (transient lock / fd exhaustion). The cold-load
80
+ * scanners keep the false default ("not activated"); the discard predicate
81
+ * passes true so an I/O hiccup can never hide a REAL session.
78
82
  * @returns {boolean}
79
83
  */
80
- function scanJournal(dir, budget, onLine) {
84
+ function scanJournal(dir, budget, onLine, ioErrorResult = false) {
81
85
  const journal = join(dir, 'journal.jsonl');
82
86
  if (!existsSync(journal)) return false;
83
87
  let fd;
@@ -105,7 +109,7 @@ function scanJournal(dir, budget, onLine) {
105
109
  const last = (carry + decoder.end()).trim();
106
110
  return !!(last && onLine(last));
107
111
  } catch {
108
- return false;
112
+ return ioErrorResult;
109
113
  } finally {
110
114
  if (fd !== undefined) { try { closeSync(fd); } catch {} }
111
115
  }
@@ -167,6 +171,69 @@ export function sessionHasCompletedMainTurn(dir) {
167
171
  });
168
172
  }
169
173
 
174
+ /**
175
+ * Does this session dir have at least one req of kind 'main' OR 'teammate'?
176
+ * The positive half of the discardable-session predicate. Budgeted with the
177
+ * WIDE budget (not MAIN_REQ_SCAN_BUDGET): journal req lines run ~1.1-1.3KB
178
+ * (headers + params), and time-driven heartbeat/countTokens lines can pile up
179
+ * before a real session's first main — a 256KB budget could give up early and
180
+ * misjudge a REAL session as discardable. Sessions that have a main still
181
+ * early-exit at its line; only genuinely main-less journals pay the budget.
182
+ * @param {string} dir - absolute session dir
183
+ * @returns {boolean}
184
+ */
185
+ export function sessionHasMainOrTeammateReq(dir) {
186
+ // ioErrorResult=true: a transient read error must KEEP the session (treat
187
+ // as main-bearing) — hiding a real session is the worse failure direction;
188
+ // a missing journal still returns false (discard is correct there).
189
+ return scanJournal(dir, COMPLETED_TURN_SCAN_BUDGET, (line) => {
190
+ if (!line.includes('"ph":"req"')) return false;
191
+ if (!line.includes('"kind":"main"') && !line.includes('"kind":"teammate"')) return false;
192
+ try {
193
+ const o = JSON.parse(line);
194
+ return !!(o && o.ph === 'req' && (o.kind === 'main' || o.kind === 'teammate'));
195
+ } catch { return false; } // torn line — keep scanning
196
+ }, true);
197
+ }
198
+
199
+ /**
200
+ * Discardable-session predicate (2026-07-16): a session dir that must be
201
+ * DISCARDED by every read surface — never listed, counted, followed, or used
202
+ * as a candidate in any logic. These are orphan dirs minted by Claude Code's
203
+ * quota probes (max_tokens:1, one `'quota'` user message, a throwaway
204
+ * session_id per probe — fired at launches and agent-team spawns), plus any
205
+ * torn/empty dir with no renderable identity.
206
+ *
207
+ * discard ⟺ meta.leader ABSENT (not a teammate session)
208
+ * AND no journal req line of kind 'main' or 'teammate'
209
+ *
210
+ * The 'teammate' kind clause is the safety net for a torn meta.json. Every
211
+ * real session carries a main req at/near the journal head (verified across
212
+ * the full real dataset: 18/18 main-less dirs were single quota probes), so a
213
+ * kept session early-exits its scan cheaply. Read-side only and self-healing:
214
+ * the moment a dir gains its first main req, the predicate flips and every
215
+ * surface picks it up on its next scan/poll.
216
+ *
217
+ * KEEP IN SYNC: adapter.js listV2Sessions pre-computes the same verdict
218
+ * inline (hasMainOrTeammate inside its existing full journal fold — unbounded,
219
+ * vs this predicate's 8MB budget; intentional asymmetry) and then CONFIRMS a
220
+ * discard through this predicate so the error→keep direction is shared.
221
+ * Change the rule here and there together.
222
+ *
223
+ * @param {string} dir - absolute session dir
224
+ * @param {object|null} [meta] - pre-parsed meta.json (avoids a re-read);
225
+ * omitted → read here; unreadable → treated as leaderless (journal decides)
226
+ * @returns {boolean} true = discard everywhere
227
+ */
228
+ export function isDiscardableSession(dir, meta) {
229
+ let m = meta;
230
+ if (m === undefined) {
231
+ try { m = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { m = null; }
232
+ }
233
+ if (m && m.leader) return false;
234
+ return !sessionHasMainOrTeammateReq(dir);
235
+ }
236
+
170
237
  /**
171
238
  * The newest readable, non-teammate session that HAS a main turn, as
172
239
  * `{ dir, sessionId }`, or null. "Readable" mirrors listV2Sessions' gates (spec
@@ -259,7 +259,7 @@ function imLogs(req, res, parsedUrl, isLocal, deps) {
259
259
  try {
260
260
  // wire-v2: prefer the newest v2 session (by meta.startTs, journal mtime as
261
261
  // tie-break); fall back to legacy v1 files until they are migrated.
262
- const sessions = listV2Sessions(join(LOG_DIR, project)).filter((s) => !s.leader);
262
+ const sessions = listV2Sessions(join(LOG_DIR, project)).filter((s) => !s.leader && !s.discard);
263
263
  if (sessions.length > 0) {
264
264
  sessions.sort((a, b) => (a.startTs < b.startTs ? 1 : a.startTs > b.startTs ? -1 : 0));
265
265
  latest = `v2:${project}/${sessions[0].sid}`; // 直接喂给 /api/local-log?file=
@@ -4,6 +4,7 @@ import { readdir, stat } from 'node:fs/promises';
4
4
  import { renameSyncWithRetry } from './lib/file-api.js';
5
5
  import { withFileLockAsync } from './lib/async-file-lock.js';
6
6
  import { dirSizeSync } from './lib/v2/layout.js';
7
+ import { isDiscardableSession } from './lib/v2/session-select.js';
7
8
  import { join, basename, resolve } from 'node:path';
8
9
  import { randomBytes } from 'node:crypto';
9
10
  import { LOG_DIR } from '../findcc.js';
@@ -117,6 +118,10 @@ export async function getWorkspaces() {
117
118
  const sessionDir = join(logDir, 'sessions', e.name);
118
119
  try {
119
120
  await stat(join(sessionDir, 'journal.jsonl'));
121
+ // Quota-probe orphans must not count: logCount>0 drives the
122
+ // auto -c heuristic, and a probe-only workspace would auto-continue
123
+ // into a conversation that does not exist (2026-07-16).
124
+ if (isDiscardableSession(sessionDir)) continue;
120
125
  // Folder size, not journal size — conv/blob/response files carry
121
126
  // most of a session's bytes (journal alone undercounts ~12x).
122
127
  totalSize += dirSizeSync(sessionDir);