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.
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/code-map.js +1 -4
- package/dist/commands/mcp.js +7 -7
- package/dist/commands/session-start.js +137 -15
- package/dist/core/bootstrap.js +28 -4
- package/dist/core/code-map/aggregate.js +36 -31
- package/dist/core/code-map/backend.js +4 -4
- package/dist/core/code-map/core.js +1 -0
- package/dist/core/code-map/export.js +4 -4
- package/dist/core/code-map/finalizer.js +57 -2
- package/dist/core/code-map/freshness.js +78 -13
- package/dist/core/code-map/impact.js +36 -4
- package/dist/core/code-map/indexes.js +37 -0
- package/dist/core/code-map/lang/python/index.js +4 -2
- package/dist/core/code-map/lang/query-runtime.js +2 -0
- package/dist/core/code-map/lang/typescript/index.js +4 -2
- package/dist/core/code-map/lang/usages.js +333 -0
- package/dist/core/code-map/memory-reader.js +15 -0
- package/dist/core/code-map/query.js +209 -58
- package/dist/core/code-map/refresh.js +0 -0
- package/dist/core/code-map/resolve.js +27 -2
- package/dist/core/code-map/store.js +1 -0
- package/dist/core/code-map/types.js +55 -9
- package/dist/core/code-map/vocabulary.js +6 -0
- package/dist/core/code-map/work-section.js +12 -14
- package/dist/core/context-diff.js +17 -3
- package/dist/core/entity-operations.js +14 -2
- package/dist/core/hint-aging.js +4 -1
- package/dist/core/identity.js +284 -91
- package/dist/core/io.js +192 -0
- package/dist/core/project-discovery.js +7 -1
- package/dist/core/runtime.js +99 -11
- package/dist/core/store-resolution.js +5 -21
- package/dist/facts.js +12 -12
- package/dist/facts.json +11 -11
- package/docs/code-map.md +36 -27
- 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 {
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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?.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
package/dist/core/hint-aging.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/core/identity.js
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
125
|
+
catch {
|
|
126
|
+
// skip invalid session files
|
|
106
127
|
}
|
|
107
128
|
}
|
|
108
|
-
catch {
|
|
109
|
-
// skip invalid session files
|
|
110
|
-
}
|
|
111
129
|
}
|
|
112
|
-
if (legacyPidlessCandidates.
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
|
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
|
-
|
|
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);
|