brainclaw 1.25.0 → 1.26.1

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 (37) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/commands/code-map.js +1 -4
  3. package/dist/commands/mcp.js +7 -7
  4. package/dist/commands/session-start.js +137 -15
  5. package/dist/core/bootstrap.js +28 -4
  6. package/dist/core/code-map/aggregate.js +36 -31
  7. package/dist/core/code-map/backend.js +4 -4
  8. package/dist/core/code-map/core.js +1 -0
  9. package/dist/core/code-map/export.js +4 -4
  10. package/dist/core/code-map/finalizer.js +57 -2
  11. package/dist/core/code-map/freshness.js +78 -13
  12. package/dist/core/code-map/impact.js +36 -4
  13. package/dist/core/code-map/indexes.js +37 -0
  14. package/dist/core/code-map/lang/python/index.js +4 -2
  15. package/dist/core/code-map/lang/query-runtime.js +2 -0
  16. package/dist/core/code-map/lang/typescript/index.js +4 -2
  17. package/dist/core/code-map/lang/usages.js +333 -0
  18. package/dist/core/code-map/memory-reader.js +15 -0
  19. package/dist/core/code-map/query.js +209 -58
  20. package/dist/core/code-map/refresh.js +0 -0
  21. package/dist/core/code-map/resolve.js +27 -2
  22. package/dist/core/code-map/store.js +1 -0
  23. package/dist/core/code-map/types.js +55 -9
  24. package/dist/core/code-map/vocabulary.js +6 -0
  25. package/dist/core/code-map/work-section.js +12 -14
  26. package/dist/core/context-diff.js +17 -3
  27. package/dist/core/entity-operations.js +14 -2
  28. package/dist/core/hint-aging.js +4 -1
  29. package/dist/core/identity.js +284 -91
  30. package/dist/core/io.js +192 -0
  31. package/dist/core/project-discovery.js +7 -1
  32. package/dist/core/runtime.js +99 -11
  33. package/dist/core/store-resolution.js +5 -21
  34. package/dist/facts.js +12 -12
  35. package/dist/facts.json +11 -11
  36. package/docs/code-map.md +36 -27
  37. package/package.json +1 -1
@@ -18,7 +18,7 @@
18
18
  * bclaw_work beyond that bounded wait (rule §6 rule 8).
19
19
  */
20
20
  import { readManifest } from './store.js';
21
- import { withCoarse } from './freshness.js';
21
+ import { withFreshness } from './freshness.js';
22
22
  import { readCodeLock, isLockAbandoned } from './lock.js';
23
23
  import { codeMapDir, lockPath } from './paths.js';
24
24
  import { JsonlBackend } from './backend.js';
@@ -108,9 +108,9 @@ export async function codeMapWorkSection(cwd, opts = {}) {
108
108
  return {
109
109
  enabled: true,
110
110
  matches: out.matches,
111
- freshness_badge: withCoarse({
111
+ freshness_badge: withFreshness({
112
112
  status: 'partial',
113
- details: { partial_reason: 'code_map_lock_active', lock_wait_ms: lockWaitMs },
113
+ details: { spot_check: { status: 'partial', partial_reason: 'code_map_lock_active' }, lock_wait_ms: lockWaitMs },
114
114
  }),
115
115
  lock_wait_ms: lockWaitMs,
116
116
  };
@@ -122,9 +122,9 @@ export async function codeMapWorkSection(cwd, opts = {}) {
122
122
  return {
123
123
  enabled: true,
124
124
  matches: [],
125
- freshness_badge: withCoarse({
125
+ freshness_badge: withFreshness({
126
126
  status: 'partial',
127
- details: { partial_reason: 'code_map_lock_active', lock_wait_ms: lockWaitMs },
127
+ details: { spot_check: { status: 'partial', partial_reason: 'code_map_lock_active' }, lock_wait_ms: lockWaitMs },
128
128
  }),
129
129
  lock_wait_ms: lockWaitMs,
130
130
  };
@@ -136,7 +136,7 @@ export async function codeMapWorkSection(cwd, opts = {}) {
136
136
  enabled: true,
137
137
  missing_index: 'Code Map index is empty for this project. Run `brainclaw code-map refresh --all` (or bclaw_code_refresh) before relying on find/brief.',
138
138
  matches: [],
139
- freshness_badge: withCoarse({ status: 'missing_index', details: {} }),
139
+ freshness_badge: withFreshness({ status: 'missing_index', details: {} }),
140
140
  ...(lockWaitMs !== undefined ? { lock_wait_ms: lockWaitMs } : {}),
141
141
  };
142
142
  }
@@ -144,16 +144,14 @@ export async function codeMapWorkSection(cwd, opts = {}) {
144
144
  // lazy read-path check (§6.1) returns the true freshness badge, so stale
145
145
  // results are surfaced WITH the stale badge rather than hidden.
146
146
  if (!query) {
147
+ // Use the same read-only index observation as bclaw_code_status. This keeps
148
+ // bclaw_work aligned with status/find/brief for git-HEAD drift too, while
149
+ // still avoiding any lazy refresh or source parsing.
150
+ const status = await backend.status({ cwd: ctx.cwd, preferredDirName: ctx.preferredDirName });
147
151
  return {
148
152
  enabled: true,
149
153
  matches: [],
150
- freshness_badge: withCoarse({
151
- status: manifest.freshness.status,
152
- details: {
153
- stale_file_count: manifest.freshness.stale_file_count,
154
- partial_reason: manifest.freshness.partial_reason,
155
- },
156
- }),
154
+ freshness_badge: status.freshness_badge,
157
155
  ...(lockWaitMs !== undefined ? { lock_wait_ms: lockWaitMs } : {}),
158
156
  };
159
157
  }
@@ -193,7 +191,7 @@ export function codeMapRefreshNextActions(section) {
193
191
  },
194
192
  ];
195
193
  }
196
- if (section.freshness_badge?.status?.startsWith('stale_')) {
194
+ if (section.freshness_badge?.freshness === 'stale') {
197
195
  return [
198
196
  {
199
197
  tool: 'bclaw_code_refresh',
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import { readAuditLog } from './audit.js';
3
3
  import { listCandidates } from './candidates.js';
4
- import { entityRecordPaths } from './io.js';
4
+ import { sessionSnapshotRecordPaths } from './io.js';
5
5
  import { loadVersionedJsonFile } from './migration.js';
6
6
  import { buildNotificationSummary, hasEventCursor, readUnseenEvents, seedCursorToEnd } from './event-log.js';
7
7
  import { SessionSnapshotSchema } from './schema.js';
@@ -44,11 +44,25 @@ export function resolveContextDiffSince(options) {
44
44
  * Uses the shared primitive rather than a fourth hand-rolled pair of paths (io.ts).
45
45
  */
46
46
  function loadSessionSnapshot(sessionId, cwd) {
47
- for (const snapshotPath of entityRecordPaths('sessions', sessionId, cwd ?? process.cwd())) {
47
+ // pln#670 snapshots carry a type-suffixed name (`<id>.snapshot.json`);
48
+ // the helper also probes the pre-split `<id>.json` layouts.
49
+ for (const snapshotPath of sessionSnapshotRecordPaths(sessionId, cwd ?? process.cwd())) {
48
50
  if (!fs.existsSync(snapshotPath))
49
51
  continue;
50
52
  try {
51
- return SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', snapshotPath).document);
53
+ // Deliberately NOT type-strict (pln#649 + pln#670): this reader answers
54
+ // "when did session X start" to anchor the diff window, and a
55
+ // current_session record for the same id is an equally authoritative
56
+ // source of `started_at`. The suffixed probes come first, so a real
57
+ // snapshot (richer: context_target, git_sha) still wins when both exist.
58
+ // Contrast with session-start's loadSessionSnapshot, which must stay
59
+ // strict — its consumers treat the record as a genuine snapshot.
60
+ const snapshot = SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', snapshotPath).document);
61
+ // A suffix-bearing lookup can construct another snapshot's path (codex
62
+ // review). Its started_at is useful only when the stored identity matches.
63
+ if (snapshot.session_id !== sessionId)
64
+ continue;
65
+ return snapshot;
52
66
  }
53
67
  catch {
54
68
  // An unparseable record in one layout must not mask a good one in the other.
@@ -28,7 +28,7 @@ import { deleteAssignment, listAssignments, loadAssignment, saveAssignment, tran
28
28
  import { listAgentRuns } from './agentruns.js';
29
29
  import { reconcileAgentRun, reconcileDeadPidRunningAgentRunAtRead, reconcileStrandedFailureClaimAtRead, TERMINAL_STATUSES } from './agentrun-reconciler.js';
30
30
  import { isObserverMode } from './observer-mode.js';
31
- import { deleteRuntimeNote, listRuntimeNotes, saveRuntimeNote, } from './runtime.js';
31
+ import { deleteRuntimeNote, listRuntimeNotes, parkRuntimeNoteBackup, saveRuntimeNote, } from './runtime.js';
32
32
  import { createSequence, deleteSequence, listSequences, updateSequence, } from './sequence.js';
33
33
  import { createConstraint, createDecision, createTrap, } from './operations/memory-write.js';
34
34
  import { deleteMemoryItem, findMemoryItemInChain, updateMemoryItem, } from './operations/memory-mutation.js';
@@ -853,10 +853,22 @@ export function removeEntity(name, id, cwd, purge = false) {
853
853
  const note = notes.find((n) => n.id === id);
854
854
  if (!note)
855
855
  throw new EntityNotFoundError(name, id);
856
+ // trp_dc9ca61e — the tool contract says "archives by default", but this
857
+ // path hard-deleted regardless of `purge` (runtime_note has no lifecycle,
858
+ // so there was no soft state to land in). Default remove now parks the
859
+ // raw record under gc-backups — the same net the retention sweeps use —
860
+ // and fails CLOSED when the park is impossible: silently downgrading an
861
+ // archive into a hard-delete is the defect being fixed.
862
+ if (!purge) {
863
+ const backupPath = parkRuntimeNoteBackup(note, cwd);
864
+ if (!backupPath) {
865
+ throw new Error(`runtime_note '${id}' could not be archived to gc-backups; pass purge:true to hard-delete`);
866
+ }
867
+ }
856
868
  const ok = deleteRuntimeNote(note, cwd);
857
869
  if (!ok)
858
870
  throw new EntityNotFoundError(name, id);
859
- return { entity: name, id, archived: false, purged: true };
871
+ return { entity: name, id, archived: !purge, purged: purge };
860
872
  }
861
873
  case 'candidate': {
862
874
  // Remove = archive to rejected. `purge` would delete the file; not exposed yet.
@@ -123,7 +123,10 @@ export function ageStaleWarnings(warnings, cwd, options = {}) {
123
123
  const overflow = ids.length - shown.length;
124
124
  return `${ids.length} ${entity}${ids.length === 1 ? '' : 's'}: ${shown.join(', ')}${overflow > 0 ? ` +${overflow} more` : ''}`;
125
125
  });
126
- aggregate = `${folded.length} stale item${folded.length === 1 ? '' : 's'} you've already been offered (${parts.join('; ')}) — bclaw_get each id to review, or bclaw_transition to retire.`;
126
+ // trp_dc9ca61e do not recommend bclaw_transition for runtime_notes: they
127
+ // have no lifecycle and the call errors. bclaw_remove (archive by default)
128
+ // is their retirement path.
129
+ aggregate = `${folded.length} stale item${folded.length === 1 ? '' : 's'} you've already been offered (${parts.join('; ')}) — bclaw_get each id to review; retire with bclaw_transition, or bclaw_remove for runtime_notes (no lifecycle).`;
127
130
  }
128
131
  return { warnings: detail, aggregate, served_ids, folded_ids };
129
132
  }
@@ -6,18 +6,19 @@ import { detectAiAgent } from './ai-agent-detection.js';
6
6
  import { requireRegisteredAgentIdentity } from './agent-registry.js';
7
7
  import { loadConfig } from './config.js';
8
8
  import { resolveCurrentHostId } from './host.js';
9
- import { memoryDir } from './io.js';
9
+ import { assertSafeSessionId, findSessionAnchorRoot, isSafeSessionId, isSessionSnapshotRecordFilename, memoryDir } from './io.js';
10
10
  import { loadVersionedJsonFile, saveVersionedJsonFile } from './migration.js';
11
11
  import { CurrentSessionStateSchema } from './schema.js';
12
12
  const SESSIONS_DIR = 'sessions';
13
13
  const LEGACY_SESSION_FILE = '.current-session';
14
14
  // --- Public API ---
15
15
  export function resolveCurrentSessionId(env = process.env, cwd, options = {}) {
16
- const value = env.BRAINCLAW_SESSION_ID?.trim()
17
- || env.OPENCLAW_SESSION_ID?.trim()
18
- || env.CLAUDE_SESSION_ID?.trim()
19
- || env.COPILOT_SESSION_ID?.trim();
20
- if (value && value.length > 0) {
16
+ // pln#672 review P1: ONE validated resolution for the env session id. This
17
+ // used to re-read the variables raw, so an unsafe value bypassed the
18
+ // boundary here and reached startSession's snapshot write — the traversal
19
+ // stayed exploitable through a second writer (reproduced by the reviewer).
20
+ const value = resolveExplicitSessionId(env);
21
+ if (value) {
21
22
  return value;
22
23
  }
23
24
  const agentName = options.agentName?.trim();
@@ -73,7 +74,6 @@ export function resolveEventSessionId(event) {
73
74
  * Checks sessions/ directory first, falls back to legacy .current-session.
74
75
  */
75
76
  export function loadCurrentSession(cwd) {
76
- const dir = sessionsDir(cwd);
77
77
  const currentUser = resolveCurrentUser();
78
78
  const currentAgent = resolveCurrentAgentName();
79
79
  const explicitSessionId = resolveExplicitSessionId();
@@ -83,34 +83,52 @@ export function loadCurrentSession(cwd) {
83
83
  const explicit = loadSessionById(explicitSessionId, cwd);
84
84
  return explicit && isSessionAlive(explicit, ttlMs, now) ? explicit : undefined;
85
85
  }
86
- // 1. Look in sessions/ directory for the session owned by this process.
87
- // Multiple parallel agents can have the same agent name/user in one repo;
88
- // a live different PID is a different agent instance, not our session.
89
- if (fs.existsSync(dir) && currentAgent) {
90
- const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
91
- const legacyPidlessCandidates = [];
92
- for (const file of files) {
93
- try {
94
- const session = loadSessionFile(path.join(dir, file));
95
- // Strict match: agent name must match, user must match (when both are known)
96
- if (session.agent !== currentAgent)
97
- continue;
98
- const userMatch = !session.user || !currentUser || session.user === currentUser;
99
- if (!userMatch || !isSessionAlive(session, ttlMs, now))
100
- continue;
101
- if (session.pid === process.pid) {
102
- return session;
86
+ // 1. Look in the sessions read-chain (workspace anchor first, then the
87
+ // pre-anchor legacy location pln#648) for the session owned by this
88
+ // process. Multiple parallel agents can have the same agent name/user in
89
+ // one repo; a live different PID is a different agent instance, not our
90
+ // session. Pidless legacy candidates are deduped by id: during relocation a
91
+ // record can transiently exist in both locations, and a duplicate must not
92
+ // inflate the candidate count.
93
+ if (currentAgent) {
94
+ const currentHostId = resolveCurrentHostId();
95
+ const legacyPidlessCandidates = new Map();
96
+ for (const dir of sessionsDirs(cwd)) {
97
+ if (!fs.existsSync(dir))
98
+ continue;
99
+ for (const file of listCurrentSessionFiles(dir)) {
100
+ try {
101
+ const session = loadSessionFile(path.join(dir, file));
102
+ // Strict match: agent name must match, user must match (when both are known)
103
+ if (session.agent !== currentAgent)
104
+ continue;
105
+ const userMatch = !session.user || !currentUser || session.user === currentUser;
106
+ if (!userMatch || !isSessionAlive(session, ttlMs, now))
107
+ continue;
108
+ if (session.pid === process.pid) {
109
+ return session;
110
+ }
111
+ // Legacy pidless adoption, HOST-GUARDED (pln#648 anchoring follow-up):
112
+ // anchoring parks every session of the workspace at one directory, so
113
+ // the historical weak adoption — agent name + user only — would now
114
+ // see records it never saw before, including another instance's
115
+ // stale intent (the exact hijack the P1-1/P1-2 review pins forbid on
116
+ // the resolver's added probes). A pidless record is only adoptable
117
+ // when it was written by THIS host; foreign-instance records need
118
+ // strong identity (named id or pid) everywhere.
119
+ if (session.pid === undefined
120
+ && (!session.host_id || session.host_id === currentHostId)
121
+ && !legacyPidlessCandidates.has(session.session_id)) {
122
+ legacyPidlessCandidates.set(session.session_id, session);
123
+ }
103
124
  }
104
- if (session.pid === undefined) {
105
- legacyPidlessCandidates.push(session);
125
+ catch {
126
+ // skip invalid session files
106
127
  }
107
128
  }
108
- catch {
109
- // skip invalid session files
110
- }
111
129
  }
112
- if (legacyPidlessCandidates.length === 1) {
113
- return legacyPidlessCandidates[0];
130
+ if (legacyPidlessCandidates.size === 1) {
131
+ return [...legacyPidlessCandidates.values()][0];
114
132
  }
115
133
  }
116
134
  // 2. Legacy fallback: .current-session
@@ -133,39 +151,61 @@ export function loadCurrentSession(cwd) {
133
151
  * Load a specific session by ID.
134
152
  */
135
153
  export function loadSessionById(sessionId, cwd) {
136
- const filepath = sessionFilePath(sessionId, cwd);
137
- if (!fs.existsSync(filepath))
138
- return undefined;
139
- try {
140
- const migration = loadVersionedJsonFile('current_session', filepath);
141
- return {
142
- ...CurrentSessionStateSchema.parse(migration.document),
143
- schema_version: migration.metadata.currentVersion,
144
- };
145
- }
146
- catch {
154
+ // A READ answers "no such record" rather than throwing: an unsafe id
155
+ // (traversal — pln#672) or the reserved '.snapshot' alias (pln#670) simply
156
+ // cannot name a current_session record. The throwing guard stays in
157
+ // sessionFilePathIn for the write paths.
158
+ if (!isSafeSessionId(sessionId) || !isCurrentSessionFilename(sessionId))
147
159
  return undefined;
160
+ // pln#648 read-chain: anchor first, then the pre-anchor legacy location. A
161
+ // bad record in one location must not mask a good one in the other.
162
+ for (const dir of sessionsDirs(cwd)) {
163
+ const filepath = sessionFilePathIn(dir, sessionId);
164
+ if (!fs.existsSync(filepath))
165
+ continue;
166
+ try {
167
+ const migration = loadVersionedJsonFile('current_session', filepath);
168
+ const session = {
169
+ ...CurrentSessionStateSchema.parse(migration.document),
170
+ schema_version: migration.metadata.currentVersion,
171
+ };
172
+ // The filename is only an index, never an identity (codex review): do not
173
+ // adopt a record whose payload names a different session.
174
+ if (session.session_id === sessionId)
175
+ return session;
176
+ }
177
+ catch {
178
+ // fall through to the next location
179
+ }
148
180
  }
181
+ return undefined;
149
182
  }
150
183
  /**
151
184
  * Load ALL sessions (active + stale) from the sessions/ directory.
152
185
  */
153
186
  export function loadAllSessions(cwd) {
154
- const dir = sessionsDir(cwd);
155
- if (!fs.existsSync(dir))
156
- return [];
157
- const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
187
+ // pln#648 read-chain: anchored records win over a transient pre-anchor copy
188
+ // of the same session (dedup by id, anchor scanned first).
189
+ const seen = new Set();
158
190
  const sessions = [];
159
- for (const file of files) {
160
- try {
161
- const migration = loadVersionedJsonFile('current_session', path.join(dir, file));
162
- sessions.push({
163
- ...CurrentSessionStateSchema.parse(migration.document),
164
- schema_version: migration.metadata.currentVersion,
165
- });
166
- }
167
- catch {
168
- // skip invalid
191
+ for (const dir of sessionsDirs(cwd)) {
192
+ if (!fs.existsSync(dir))
193
+ continue;
194
+ for (const file of listCurrentSessionFiles(dir)) {
195
+ try {
196
+ const migration = loadVersionedJsonFile('current_session', path.join(dir, file));
197
+ const session = {
198
+ ...CurrentSessionStateSchema.parse(migration.document),
199
+ schema_version: migration.metadata.currentVersion,
200
+ };
201
+ if (seen.has(session.session_id))
202
+ continue;
203
+ seen.add(session.session_id);
204
+ sessions.push(session);
205
+ }
206
+ catch {
207
+ // skip invalid
208
+ }
169
209
  }
170
210
  }
171
211
  return sessions.sort((a, b) => b.last_seen_at.localeCompare(a.last_seen_at));
@@ -178,30 +218,65 @@ export function saveCurrentSession(session, cwd) {
178
218
  if (!fs.existsSync(dir)) {
179
219
  fs.mkdirSync(dir, { recursive: true });
180
220
  }
221
+ // sessionFilePath throws on a '.snapshot' alias id — a write must never
222
+ // construct a snapshot filename (codex review P1).
181
223
  const filepath = sessionFilePath(session.session_id, cwd);
224
+ // A plain `<id>.json` can still hold a pre-split snapshot (the old
225
+ // 'read'-mode write bug parked snapshots in session directories). Only
226
+ // overwrite THIS path when its record proves to be this exact
227
+ // current_session entry (codex review, made path-local by pln#648: the
228
+ // proof must be about the file being replaced, not about any location).
229
+ if (fs.existsSync(filepath) && !isProvenCurrentSessionAt(filepath, session.session_id)) {
230
+ throw new Error(`Refusing to overwrite non-current_session record at '${filepath}'`);
231
+ }
182
232
  saveVersionedJsonFile('current_session', filepath, CurrentSessionStateSchema.parse(session));
233
+ // pln#648 relocation: a pre-anchor copy of the SAME session under the
234
+ // effective cwd would linger until TTL decay — remove it once the anchored
235
+ // write has landed, on positive proof only. Best effort: the read-chain and
236
+ // the GC cover any leftover. legacySessionsDir normalizes exactly like the
237
+ // anchor (codex review P1): a relative cwd must never make the SAME
238
+ // directory compare unequal — the unlink below would delete the record
239
+ // this function just wrote.
240
+ const legacyDir = legacySessionsDir(cwd);
241
+ if (legacyDir !== dir) {
242
+ try {
243
+ const legacyPath = sessionFilePathIn(legacyDir, session.session_id);
244
+ if (fs.existsSync(legacyPath) && isProvenCurrentSessionAt(legacyPath, session.session_id)) {
245
+ fs.unlinkSync(legacyPath);
246
+ }
247
+ }
248
+ catch { /* best effort */ }
249
+ }
183
250
  }
184
251
  /**
185
252
  * Clear a session. If sessionId is provided, only clear that specific session.
186
253
  */
187
254
  export function clearCurrentSession(cwd, sessionId) {
188
- if (sessionId) {
189
- // Remove specific session file
190
- const filepath = sessionFilePath(sessionId, cwd);
191
- try {
192
- fs.unlinkSync(filepath);
255
+ // A filename is never enough authority to delete a record (codex review P1):
256
+ // the '.snapshot' alias id throws in sessionFilePathIn (caught → no-op), and
257
+ // a file is only unlinked when it PROVES to be this exact current_session
258
+ // record — never a pre-split snapshot parked under a plain `<id>.json`.
259
+ // pln#648: the record can live at the anchor OR at the pre-anchor legacy
260
+ // location — clear wherever it proves.
261
+ const unlinkProven = (id) => {
262
+ for (const dir of sessionsDirs(cwd)) {
263
+ try {
264
+ const filepath = sessionFilePathIn(dir, id);
265
+ if (fs.existsSync(filepath) && isProvenCurrentSessionAt(filepath, id)) {
266
+ fs.unlinkSync(filepath);
267
+ }
268
+ }
269
+ catch { /* ignore */ }
193
270
  }
194
- catch { /* ignore */ }
271
+ };
272
+ if (sessionId) {
273
+ unlinkProven(sessionId);
195
274
  return;
196
275
  }
197
276
  // Clear the session for the current agent+user
198
277
  const session = loadCurrentSession(cwd);
199
278
  if (session) {
200
- const filepath = sessionFilePath(session.session_id, cwd);
201
- try {
202
- fs.unlinkSync(filepath);
203
- }
204
- catch { /* ignore */ }
279
+ unlinkProven(session.session_id);
205
280
  }
206
281
  // Also clean legacy file
207
282
  const legacyPath = path.join(memoryDir(cwd), LEGACY_SESSION_FILE);
@@ -215,42 +290,130 @@ export function clearCurrentSession(cwd, sessionId) {
215
290
  * Returns the number of sessions removed.
216
291
  */
217
292
  export function gcStaleSessions(cwd, ttlOverride) {
218
- const dir = sessionsDir(cwd);
219
- if (!fs.existsSync(dir))
220
- return 0;
221
293
  const ttlMs = parseDurationToMs(ttlOverride ?? loadConfigSafe(cwd)?.implicit_session_ttl ?? '4h');
222
294
  const now = Date.now();
223
295
  let removed = 0;
224
- const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
225
- for (const file of files) {
226
- try {
227
- const migration = loadVersionedJsonFile('current_session', path.join(dir, file));
228
- const session = {
229
- ...CurrentSessionStateSchema.parse(migration.document),
230
- schema_version: migration.metadata.currentVersion,
231
- };
232
- if (now - Date.parse(session.last_seen_at) > ttlMs) {
233
- fs.unlinkSync(path.join(dir, file));
234
- removed++;
235
- }
236
- }
237
- catch {
238
- // Remove unparseable files too
296
+ // pln#648: sweep the whole read-chain — pre-anchor legacy records are
297
+ // exactly what this GC must decay.
298
+ for (const dir of sessionsDirs(cwd)) {
299
+ if (!fs.existsSync(dir))
300
+ continue;
301
+ for (const file of listCurrentSessionFiles(dir)) {
302
+ const filepath = path.join(dir, file);
239
303
  try {
240
- fs.unlinkSync(path.join(dir, file));
241
- removed++;
304
+ // POSITIVE proof before deletion (codex review): a bare `<id>.json` in
305
+ // this directory can still be a pre-split snapshot (old 'read'-mode
306
+ // write bug). Only a record carrying the current_session discriminant
307
+ // may be collected; anything unidentifiable is preserved, never deleted.
308
+ const raw = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
309
+ if (typeof raw.last_seen_at !== 'string')
310
+ continue;
311
+ const migration = loadVersionedJsonFile('current_session', filepath);
312
+ const session = {
313
+ ...CurrentSessionStateSchema.parse(migration.document),
314
+ schema_version: migration.metadata.currentVersion,
315
+ };
316
+ if (now - Date.parse(session.last_seen_at) > ttlMs) {
317
+ fs.unlinkSync(filepath);
318
+ removed++;
319
+ }
320
+ }
321
+ catch {
322
+ // An unidentifiable record is not proven stale current_session state —
323
+ // preserving it beats risking the deletion of a legacy snapshot.
242
324
  }
243
- catch { /* ignore */ }
244
325
  }
245
326
  }
246
327
  return removed;
247
328
  }
248
329
  // --- Internal helpers ---
330
+ /**
331
+ * pln#648 (a) — the session record must live at a STABLE, workspace-unique
332
+ * location. Anchored on the effective cwd, the record landed under the store
333
+ * of the project being LEFT at session-start, and every switch moved the
334
+ * truth out of the resolver's reach (the reproduced P0: status said api,
335
+ * writes went to web). The anchor is the outermost .brainclaw/ above cwd —
336
+ * a pure filesystem answer, independent of active-project state, so every
337
+ * probe of the same workspace derives the SAME directory.
338
+ */
339
+ function sessionAnchorCwd(cwd) {
340
+ // path.resolve BEFORE anchoring (codex review P1): the anchor and the legacy
341
+ // location below are compared for equality — a relative cwd ('.') must not
342
+ // make the SAME directory look like two, or the relocation would unlink the
343
+ // record it just wrote. Role-aware walk: the nearest declared workspace wins,
344
+ // so sibling workspaces under a parent store stay isolated (review P1 #2).
345
+ const base = path.resolve(cwd ?? process.cwd());
346
+ return findSessionAnchorRoot(base) ?? base;
347
+ }
348
+ /** The write + primary read location for current_session records. */
249
349
  function sessionsDir(cwd) {
250
- return path.join(memoryDir(cwd), SESSIONS_DIR);
350
+ return path.join(memoryDir(sessionAnchorCwd(cwd)), SESSIONS_DIR);
351
+ }
352
+ /** The pre-anchor legacy location, NORMALIZED the same way as the anchor. */
353
+ function legacySessionsDir(cwd) {
354
+ return path.join(memoryDir(path.resolve(cwd ?? process.cwd())), SESSIONS_DIR);
355
+ }
356
+ /**
357
+ * Read-chain (pln#648 migration): anchor first, then the pre-anchor location
358
+ * under the effective cwd where existing records still live. Sessions expire
359
+ * within the implicit TTL (4h), so the legacy probe decays naturally — no
360
+ * rewrite migration; saveCurrentSession relocates its own record on the next
361
+ * heartbeat and the GC sweeps both. Deduped when both resolve to the same
362
+ * directory (single-project stores — the common case).
363
+ */
364
+ function sessionsDirs(cwd) {
365
+ const anchored = sessionsDir(cwd);
366
+ const legacy = legacySessionsDir(cwd);
367
+ return anchored === legacy ? [anchored] : [anchored, legacy];
368
+ }
369
+ /**
370
+ * Positive proof that the file at `filepath` is THE current_session record for
371
+ * `sessionId` (pln#670 discipline: a filename is never authority to delete or
372
+ * overwrite — a plain `<id>.json` can be a pre-split snapshot).
373
+ */
374
+ function isProvenCurrentSessionAt(filepath, sessionId) {
375
+ try {
376
+ const raw = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
377
+ return typeof raw.last_seen_at === 'string' && raw.session_id === sessionId;
378
+ }
379
+ catch {
380
+ return false;
381
+ }
382
+ }
383
+ /**
384
+ * pln#670 — current_session scanners must be type-strict: session_snapshot
385
+ * records use the `<id>.snapshot.json` suffix and can share a directory with
386
+ * current_session records. Without this exclusion, gcStaleSessions would
387
+ * delete a stray snapshot as "unparseable" and loadCurrentSession could adopt
388
+ * one as a session candidate.
389
+ */
390
+ function listCurrentSessionFiles(dir) {
391
+ // Case-insensitive on purpose (codex review P1): Windows filesystems match
392
+ // names case-insensitively, so `X.SNAPSHOT.json` IS the snapshot path a
393
+ // lower-case probe resolves — excluding only the exact-case suffix would let
394
+ // gcStaleSessions delete it as an unparseable current_session.
395
+ return fs.readdirSync(dir).filter(f => f.toLowerCase().endsWith('.json') && !isSessionSnapshotRecordFilename(f));
396
+ }
397
+ /** True when `<sessionId>.json` cannot collide with a session_snapshot filename. */
398
+ function isCurrentSessionFilename(sessionId) {
399
+ return !isSessionSnapshotRecordFilename(`${sessionId}.json`);
400
+ }
401
+ function sessionFilePathIn(dir, sessionId) {
402
+ // pln#672 — PATH SAFETY first: the id is env-controlled and becomes a
403
+ // filename, so a traversal ('../../evil') must never build a path. This is
404
+ // the choke point for save / load / clear alike.
405
+ assertSafeSessionId(sessionId);
406
+ // pln#670 review fix (codex P1): a session id ending in ".snapshot" would
407
+ // produce `<base>.snapshot.json` — the snapshot filename of session <base>
408
+ // in a shared directory. Refuse the alias instead of silently colliding
409
+ // across record types; readers treat the throw as "no such record".
410
+ if (!isCurrentSessionFilename(sessionId)) {
411
+ throw new Error(`session id '${sessionId}' is reserved for session_snapshot records — the '.snapshot' suffix would collide across record types`);
412
+ }
413
+ return path.join(dir, `${sessionId}.json`);
251
414
  }
252
415
  function sessionFilePath(sessionId, cwd) {
253
- return path.join(sessionsDir(cwd), `${sessionId}.json`);
416
+ return sessionFilePathIn(sessionsDir(cwd), sessionId);
254
417
  }
255
418
  function resolveCurrentUser() {
256
419
  return process.env.USER || process.env.USERNAME || os.userInfo().username || undefined;
@@ -269,11 +432,41 @@ function resolveCurrentAgentName() {
269
432
  * resolution from a store the agent never named.
270
433
  */
271
434
  export function resolveExplicitSessionId(env = process.env) {
272
- return env.BRAINCLAW_SESSION_ID?.trim()
435
+ const named = env.BRAINCLAW_SESSION_ID?.trim()
273
436
  || env.OPENCLAW_SESSION_ID?.trim()
274
437
  || env.CLAUDE_SESSION_ID?.trim()
275
438
  || env.COPILOT_SESSION_ID?.trim()
276
439
  || undefined;
440
+ // pln#672 — this value becomes a FILENAME (`<id>.json`). An id that cannot
441
+ // safely do so is IGNORED, not honoured: falling back to the implicit
442
+ // session is safe, while using it walks out of the store (reproduced on
443
+ // disk with '../../../ESCAPED'). Refusing to run at all would be worse —
444
+ // a stale exported variable would break every command in the shell — so
445
+ // the boundary drops the value here and the filename builders below refuse
446
+ // loudly for any other caller. The drop is NOT silent: session
447
+ // establishment surfaces it (see describeIgnoredSessionIdEnv).
448
+ return named && isSafeSessionId(named) ? named : undefined;
449
+ }
450
+ /**
451
+ * Name the env variable whose session id was DROPPED as unsafe, if any
452
+ * (pln#672 review — the availability-first fallback must not silently change
453
+ * the agent's identity: continuing under an implicit session while the caller
454
+ * believes it resumed the supplied one is exactly the kind of silent
455
+ * divergence this project refuses).
456
+ *
457
+ * Returns the VARIABLE NAME and a non-sensitive reason only — never the raw
458
+ * value, which is attacker-influenced and would land in logs.
459
+ */
460
+ export function describeIgnoredSessionIdEnv(env = process.env) {
461
+ for (const variable of ['BRAINCLAW_SESSION_ID', 'OPENCLAW_SESSION_ID', 'CLAUDE_SESSION_ID', 'COPILOT_SESSION_ID']) {
462
+ const raw = env[variable]?.trim();
463
+ if (!raw)
464
+ continue;
465
+ // The first variable that carries a value decides — same precedence as
466
+ // resolveExplicitSessionId, so the report names the one actually used.
467
+ return isSafeSessionId(raw) ? undefined : { variable, length: raw.length };
468
+ }
469
+ return undefined;
277
470
  }
278
471
  function loadSessionFile(filepath) {
279
472
  const migration = loadVersionedJsonFile('current_session', filepath);