brainclaw 0.28.0 → 0.29.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.
package/dist/cli.js CHANGED
@@ -1157,6 +1157,17 @@ program
1157
1157
  cwd: globalOpts.cwd,
1158
1158
  });
1159
1159
  });
1160
+ program
1161
+ .command('who')
1162
+ .description('Show active agent sessions on this workspace')
1163
+ .option('--json', 'Output as JSON')
1164
+ .option('--all', 'Include stale sessions')
1165
+ .option('--gc', 'Remove stale sessions')
1166
+ .action(async (options) => {
1167
+ const globalOpts = program.opts();
1168
+ const { runWho } = await import('./commands/who.js');
1169
+ runWho({ json: options.json, all: options.all, gc: options.gc, cwd: globalOpts.cwd });
1170
+ });
1160
1171
  program.parseAsync(process.argv).catch((err) => {
1161
1172
  console.error(err);
1162
1173
  process.exit(1);
@@ -9,6 +9,7 @@ import { buildContext, renderContextMarkdown, renderContextPromptTemplate } from
9
9
  import { buildExecutionContext, renderExecutionContextSummary } from '../core/execution-context.js';
10
10
  import { checkBrainclawInstallableUpdate, renderBrainclawInstallableUpdateNotice } from '../core/brainclaw-version.js';
11
11
  import { loadConfig } from '../core/config.js';
12
+ import { loadAllSessions, gcStaleSessions } from '../core/identity.js';
12
13
  import { loadState, persistState, saveState } from '../core/state.js';
13
14
  import { memoryExists } from '../core/io.js';
14
15
  import { generateCandidateIdWithLabel, listArchivedCandidates, listCandidates, saveCandidate } from '../core/candidates.js';
@@ -307,6 +308,17 @@ export const MCP_READ_TOOLS = [
307
308
  },
308
309
  },
309
310
  },
311
+ {
312
+ name: 'bclaw_who',
313
+ description: 'List all active agent sessions on this workspace. Shows user, agent, active project, claims, and last activity for each session.',
314
+ inputSchema: {
315
+ type: 'object',
316
+ properties: {
317
+ all: { type: 'boolean', description: 'Include stale sessions (default: false).' },
318
+ gc: { type: 'boolean', description: 'Remove stale sessions and return count.' },
319
+ },
320
+ },
321
+ },
310
322
  ];
311
323
  const MCP_WRITE_TOOLS = [
312
324
  {
@@ -1767,6 +1779,42 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
1767
1779
  structuredContent: { agent: currentAgentName, conflicts, total: conflicts.length, schema_version: SCHEMA_VERSION },
1768
1780
  };
1769
1781
  }
1782
+ if (name === 'bclaw_who') {
1783
+ // loadAllSessions and gcStaleSessions imported at top of file
1784
+ const doGc = args.gc === true;
1785
+ const showAll = args.all === true;
1786
+ if (doGc) {
1787
+ const removed = gcStaleSessions(cwd);
1788
+ return {
1789
+ content: [{ type: 'text', text: `✔ Removed ${removed} stale session(s).` }],
1790
+ structuredContent: { gc: true, removed, schema_version: SCHEMA_VERSION },
1791
+ };
1792
+ }
1793
+ const allSessions = loadAllSessions(cwd);
1794
+ const ttlMs = 4 * 60 * 60 * 1000;
1795
+ const now = Date.now();
1796
+ const sessions = showAll
1797
+ ? allSessions
1798
+ : allSessions.filter((s) => (now - Date.parse(s.last_seen_at)) <= ttlMs);
1799
+ const activeClaims = listClaims(cwd).filter((c) => c.status === 'active');
1800
+ const output = sessions.map((s) => ({
1801
+ session_id: s.session_id,
1802
+ user: s.user ?? 'unknown',
1803
+ agent: s.agent,
1804
+ agent_id: s.agent_id,
1805
+ project: s.active_project?.name ?? s.active_project?.path ?? null,
1806
+ claims: activeClaims.filter((c) => c.agent_id === s.agent_id).length,
1807
+ last_seen_at: s.last_seen_at,
1808
+ stale: (now - Date.parse(s.last_seen_at)) > ttlMs,
1809
+ }));
1810
+ const lines = sessions.length === 0
1811
+ ? 'No active sessions.'
1812
+ : output.map((s) => `${s.user} | ${s.agent} | ${s.project ?? '(root)'} | ${s.claims} claims | ${s.stale ? 'stale' : 'active'}`).join('\n');
1813
+ return {
1814
+ content: [{ type: 'text', text: lines }],
1815
+ structuredContent: { sessions: output, total: output.length, schema_version: SCHEMA_VERSION },
1816
+ };
1817
+ }
1770
1818
  if (name === 'bclaw_doctor') {
1771
1819
  // Capture doctor JSON output by redirecting console.log
1772
1820
  const captured = [];
@@ -2188,6 +2236,7 @@ export async function executeMcpToolCall(payload) {
2188
2236
  id: claimId,
2189
2237
  agent: identity.agent,
2190
2238
  agent_id: identity.agent_id,
2239
+ user: process.env.USER || process.env.USERNAME || undefined,
2191
2240
  project_id: identity.project_id,
2192
2241
  host_id: identity.host_id,
2193
2242
  session_id: identity.session_id,
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { loadActiveProject, saveActiveProject, clearActiveProject } from '../core/active-project.js';
3
+ import { loadCurrentSession, saveCurrentSession } from '../core/identity.js';
3
4
  import { memoryExists } from '../core/io.js';
4
5
  import { resolveProjectRef, resolveWorkspaceRoot } from '../core/store-resolution.js';
5
6
  import { scanNestedBrainclawProjects } from '../core/workspace-projects.js';
@@ -18,6 +19,11 @@ export function runSwitch(projectRef, options = {}) {
18
19
  }
19
20
  // --clear: remove active project
20
21
  if (options.clear) {
22
+ const session = loadCurrentSession(cwd);
23
+ if (session?.active_project) {
24
+ const { active_project: _removed, ...rest } = session;
25
+ saveCurrentSession(rest, cwd);
26
+ }
21
27
  clearActiveProject(wsRoot);
22
28
  if (options.json) {
23
29
  console.log(JSON.stringify({ cleared: true }));
@@ -47,18 +53,35 @@ export function runSwitch(projectRef, options = {}) {
47
53
  catch {
48
54
  // name is optional
49
55
  }
50
- saveActiveProject(wsRoot, {
51
- path: resolved,
52
- name: projectName,
53
- switched_at: new Date().toISOString(),
54
- switched_by: process.env.BRAINCLAW_AGENT_NAME ?? process.env.USER ?? 'unknown',
55
- });
56
+ const now = new Date().toISOString();
57
+ const session = loadCurrentSession(cwd);
58
+ const scopedToSession = options.session ?? !!session;
59
+ let scope;
60
+ if (scopedToSession && session) {
61
+ // Write to session state — only this agent sees this switch
62
+ saveCurrentSession({
63
+ ...session,
64
+ active_project: { path: resolved, name: projectName, switched_at: now },
65
+ }, cwd);
66
+ scope = 'session';
67
+ }
68
+ else {
69
+ // Fall back to global active-project.json
70
+ saveActiveProject(wsRoot, {
71
+ path: resolved,
72
+ name: projectName,
73
+ switched_at: now,
74
+ switched_by: process.env.BRAINCLAW_AGENT_NAME ?? process.env.USER ?? 'unknown',
75
+ });
76
+ scope = 'global';
77
+ }
56
78
  if (options.json) {
57
- console.log(JSON.stringify({ switched: true, path: resolved, name: projectName }));
79
+ console.log(JSON.stringify({ switched: true, path: resolved, name: projectName, scope }));
58
80
  }
59
81
  else {
60
82
  const rel = path.relative(wsRoot, resolved) || '.';
61
- console.log(`✔ Switched to ${projectName ? `"${projectName}" (${rel})` : rel}`);
83
+ const scopeHint = scope === 'session' ? ' (session-scoped)' : '';
84
+ console.log(`✔ Switched to ${projectName ? `"${projectName}" (${rel})` : rel}${scopeHint}`);
62
85
  }
63
86
  }
64
87
  function showCurrent(wsRoot, json) {
@@ -0,0 +1,96 @@
1
+ import { loadAllSessions, gcStaleSessions } from '../core/identity.js';
2
+ import { listClaims } from '../core/claims.js';
3
+ export function runWho(options = {}) {
4
+ const cwd = options.cwd ?? process.cwd();
5
+ if (options.gc) {
6
+ const removed = gcStaleSessions(cwd);
7
+ if (options.json) {
8
+ console.log(JSON.stringify({ gc: true, removed }));
9
+ }
10
+ else {
11
+ console.log(`✔ Removed ${removed} stale session(s).`);
12
+ }
13
+ return;
14
+ }
15
+ const allSessions = loadAllSessions(cwd);
16
+ const ttlMs = 4 * 60 * 60 * 1000; // 4h default
17
+ const now = Date.now();
18
+ const sessions = options.all
19
+ ? allSessions
20
+ : allSessions.filter(s => (now - Date.parse(s.last_seen_at)) <= ttlMs);
21
+ const activeClaims = listClaims(cwd).filter(c => c.status === 'active');
22
+ const enriched = sessions.map(s => {
23
+ const age = now - Date.parse(s.last_seen_at);
24
+ const stale = age > ttlMs;
25
+ const dead = s.pid ? !isPidAlive(s.pid) : false;
26
+ const status = dead ? 'dead' : stale ? 'stale' : 'active';
27
+ return {
28
+ session_id: s.session_id,
29
+ user: s.user ?? 'unknown',
30
+ agent: s.agent,
31
+ agent_id: s.agent_id,
32
+ host_id: s.host_id,
33
+ project: s.active_project?.name ?? s.active_project?.path ?? null,
34
+ claims: activeClaims.filter(c => c.agent_id === s.agent_id).length,
35
+ started_at: s.started_at,
36
+ last_seen_at: s.last_seen_at,
37
+ status,
38
+ pid: s.pid,
39
+ };
40
+ });
41
+ if (options.json) {
42
+ console.log(JSON.stringify({ sessions: enriched, total: enriched.length }, null, 2));
43
+ return;
44
+ }
45
+ if (enriched.length === 0) {
46
+ console.log('No active sessions.');
47
+ return;
48
+ }
49
+ // Table header
50
+ const header = ['USER', 'AGENT', 'PROJECT', 'CLAIMS', 'STATUS', 'LAST SEEN'];
51
+ const rows = [];
52
+ for (const s of enriched) {
53
+ const age = now - Date.parse(s.last_seen_at);
54
+ const project = s.project ?? '(workspace root)';
55
+ rows.push([
56
+ s.user,
57
+ s.agent,
58
+ project.length > 25 ? project.slice(0, 22) + '...' : project,
59
+ String(s.claims),
60
+ s.status,
61
+ formatAge(age),
62
+ ]);
63
+ }
64
+ // Calculate column widths
65
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length)));
66
+ console.log('Active sessions:\n');
67
+ console.log(' ' + header.map((h, i) => h.padEnd(widths[i])).join(' '));
68
+ console.log(' ' + widths.map(w => '─'.repeat(w)).join(' '));
69
+ for (const row of rows) {
70
+ console.log(' ' + row.map((cell, i) => cell.padEnd(widths[i])).join(' '));
71
+ }
72
+ console.log(`\n${enriched.length} session(s).`);
73
+ }
74
+ function isPidAlive(pid) {
75
+ try {
76
+ // process.kill(pid, 0) throws if process doesn't exist
77
+ process.kill(pid, 0);
78
+ return true;
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
84
+ function formatAge(ms) {
85
+ const minutes = Math.floor(ms / 60_000);
86
+ if (minutes < 1)
87
+ return 'just now';
88
+ if (minutes < 60)
89
+ return `${minutes}min ago`;
90
+ const hours = Math.floor(minutes / 60);
91
+ if (hours < 24)
92
+ return `${hours}h ago`;
93
+ const days = Math.floor(hours / 24);
94
+ return `${days}d ago`;
95
+ }
96
+ //# sourceMappingURL=who.js.map
@@ -8,7 +8,7 @@ import { resolveEntityDir } from './io.js';
8
8
  import { mutate } from './mutation-pipeline.js';
9
9
  import { BootstrapApplicationReceiptSchema, BootstrapInterviewAnswerSchema, BootstrapInterviewPlanSchema, BootstrapInterviewQuestionSchema, BootstrapImportPlanDocumentSchema, BootstrapProfileDocumentSchema, BootstrapSuggestionDocumentSchema, MemorySeedDocumentSchema, } from './schema.js';
10
10
  import { loadVersionedJsonFile, saveVersionedJsonFile } from './migration.js';
11
- import { analyzeRepository } from './repo-analysis.js';
11
+ import { analyzeRepository, findNestedAgentsFiles } from './repo-analysis.js';
12
12
  import { buildExecutionContext, compactExecutionContext } from './execution-context.js';
13
13
  import { buildAgentToolingContext } from './agent-context.js';
14
14
  import { createInstruction, loadInstructions, saveInstruction } from './instructions.js';
@@ -101,6 +101,7 @@ export function runBootstrapProfile(options = {}) {
101
101
  importPlan,
102
102
  lastApplication,
103
103
  reusedProfile: false,
104
+ subProjects: artifacts.subProjects,
104
105
  };
105
106
  }
106
107
  export function listBootstrapSeeds(cwd) {
@@ -183,6 +184,18 @@ export function renderBootstrapSummary(result) {
183
184
  if (result.lastApplication && !result.lastApplication.uninstalled_at) {
184
185
  lines.push(`Last bootstrap import: ${result.lastApplication.managed_artifacts.length} managed artifact(s) from ${result.lastApplication.applied_at}`);
185
186
  }
187
+ if (result.subProjects && result.subProjects.length > 0) {
188
+ lines.push('');
189
+ lines.push(`Sub-projects discovered (${result.subProjects.length}):`);
190
+ for (const sp of result.subProjects.slice(0, 20)) {
191
+ lines.push(` ${sp}`);
192
+ }
193
+ if (result.subProjects.length > 20) {
194
+ lines.push(` ... and ${result.subProjects.length - 20} more`);
195
+ }
196
+ lines.push('');
197
+ lines.push('Use: brainclaw bootstrap --for <sub-project-path> --refresh');
198
+ }
186
199
  if (result.importPlan.suggestions.length > 0) {
187
200
  lines.push('');
188
201
  lines.push('Import proposal:');
@@ -231,14 +244,19 @@ export function renderBootstrapInterview(result, audience = 'any') {
231
244
  function buildBootstrapArtifacts(input) {
232
245
  const sourcesScanned = [];
233
246
  const seeds = [];
234
- const workspace = classifyWorkspace(input.cwd);
235
- const nativeInstructionFiles = discoverNativeInstructionFiles(input.cwd);
236
- const readmePath = findFirstExisting(input.cwd, README_CANDIDATES);
247
+ // When target is an absolute directory path, use it as the scan root
248
+ // so that instruction files, README, and AGENTS.md are discovered from
249
+ // the target scope — not from the workspace root. This fixes bootstrap
250
+ // returning wrong signals for monorepo sub-projects.
251
+ const scanRoot = resolveBootstrapScanRoot(input.cwd, input.target);
252
+ const workspace = classifyWorkspace(scanRoot);
253
+ const nativeInstructionFiles = discoverNativeInstructionFiles(scanRoot);
254
+ const readmePath = findFirstExisting(scanRoot, README_CANDIDATES);
237
255
  if (readmePath) {
238
256
  sourcesScanned.push('README');
239
257
  seeds.push(...extractReadmeSeeds(readmePath, input.target));
240
258
  }
241
- const agentsPath = path.join(input.cwd, 'AGENTS.md');
259
+ const agentsPath = path.join(scanRoot, 'AGENTS.md');
242
260
  const agentsPresent = fs.existsSync(agentsPath);
243
261
  if (agentsPresent) {
244
262
  sourcesScanned.push('AGENTS.md');
@@ -246,9 +264,9 @@ function buildBootstrapArtifacts(input) {
246
264
  }
247
265
  if (nativeInstructionFiles.length > 0) {
248
266
  sourcesScanned.push('native_instructions');
249
- seeds.push(...extractNativeInstructionSeeds(nativeInstructionFiles.map((relativePath) => path.join(input.cwd, relativePath)), input.cwd, input.target));
267
+ seeds.push(...extractNativeInstructionSeeds(nativeInstructionFiles.map((relativePath) => path.join(scanRoot, relativePath)), scanRoot, input.target));
250
268
  }
251
- const manifestResult = extractManifestSeeds(input.cwd, input.target);
269
+ const manifestResult = extractManifestSeeds(scanRoot, input.target);
252
270
  if (manifestResult.seeds.length > 0) {
253
271
  sourcesScanned.push(...manifestResult.sources);
254
272
  seeds.push(...manifestResult.seeds);
@@ -265,16 +283,16 @@ function buildBootstrapArtifacts(input) {
265
283
  sourcesScanned.push('local_mcp');
266
284
  seeds.push(...extractMcpSeeds(agentTooling.mcp_servers, input.target));
267
285
  }
268
- const repoAnalysis = analyzeRepository(input.cwd);
286
+ const repoAnalysis = analyzeRepository(scanRoot);
269
287
  sourcesScanned.push('repo-analysis');
270
288
  seeds.push(...extractRepoAnalysisSeeds(repoAnalysis, input.target));
271
289
  // Additional brownfield sources (step 12)
272
- const additionalSeeds = extractAdditionalBrownfieldSeeds(input.cwd, input.target);
290
+ const additionalSeeds = extractAdditionalBrownfieldSeeds(scanRoot, input.target);
273
291
  if (additionalSeeds.seeds.length > 0) {
274
292
  sourcesScanned.push(...additionalSeeds.sources);
275
293
  seeds.push(...additionalSeeds.seeds);
276
294
  }
277
- const gitProbe = probeGit(input.cwd, input.target);
295
+ const gitProbe = probeGit(scanRoot, input.target);
278
296
  if (gitProbe.available) {
279
297
  sourcesScanned.push('git');
280
298
  seeds.push(...gitProbe.hotspotSeeds);
@@ -342,6 +360,13 @@ function buildBootstrapArtifacts(input) {
342
360
  schema_version: DERIVED_SCHEMA_VERSION,
343
361
  })),
344
362
  importPlan,
363
+ // For multi-project workspaces without a target, list discovered sub-projects
364
+ subProjects: (!input.target && repoAnalysis.recommendedMode === 'multi-project')
365
+ ? findNestedAgentsFiles(scanRoot, 8)
366
+ .filter((p) => p !== 'AGENTS.md') // exclude root AGENTS.md
367
+ .map((p) => path.dirname(p))
368
+ .filter((d) => d !== '.')
369
+ : undefined,
345
370
  };
346
371
  }
347
372
  function extractReadmeSeeds(filepath, target) {
@@ -846,6 +871,9 @@ function buildSummary(input) {
846
871
  parts.push(`Onboarding mode: ${input.onboardingMode}.`);
847
872
  parts.push(`Confidence: ${input.confidence}.`);
848
873
  parts.push(`Repository mode looks ${input.repoAnalysis.recommendedMode}.`);
874
+ if (input.repoAnalysis.recommendedMode === 'multi-project' && !input.target) {
875
+ parts.push('This is a multi-project workspace — use --for <path> to bootstrap a specific sub-project.');
876
+ }
849
877
  if (input.agentsPresent) {
850
878
  parts.push('AGENTS.md detected and summarized.');
851
879
  }
@@ -1639,6 +1667,29 @@ function normalizeTarget(target) {
1639
1667
  const trimmed = target?.trim();
1640
1668
  return trimmed && trimmed.length > 0 ? trimmed : undefined;
1641
1669
  }
1670
+ /**
1671
+ * Resolve where to scan for project files (README, AGENTS.md, manifests).
1672
+ * If target is an absolute directory path, scan from there.
1673
+ * Otherwise fall back to cwd (the workspace/store root).
1674
+ */
1675
+ function resolveBootstrapScanRoot(cwd, target) {
1676
+ if (!target)
1677
+ return cwd;
1678
+ const resolved = path.isAbsolute(target) ? target : path.resolve(cwd, target);
1679
+ try {
1680
+ if (fs.statSync(resolved).isDirectory())
1681
+ return resolved;
1682
+ }
1683
+ catch { /* not a directory or doesn't exist */ }
1684
+ // Target is a file path or glob — use its parent directory if it exists
1685
+ const parent = path.dirname(resolved);
1686
+ try {
1687
+ if (fs.statSync(parent).isDirectory())
1688
+ return parent;
1689
+ }
1690
+ catch { /* fall back to cwd */ }
1691
+ return cwd;
1692
+ }
1642
1693
  // ─── Step 12: Additional brownfield sources ──────────────────────────────────
1643
1694
  const CI_WORKFLOW_DIRS = ['.github/workflows', '.gitlab'];
1644
1695
  const CI_FILES = ['.gitlab-ci.yml', 'Jenkinsfile', '.circleci/config.yml'];
@@ -1,5 +1,8 @@
1
1
  import path from 'node:path';
2
+ import { loadActiveProject } from './active-project.js';
3
+ import { checkBrainclawInstallableUpdate, renderBrainclawInstallableUpdateNotice } from './brainclaw-version.js';
2
4
  import { loadConfig } from './config.js';
5
+ import { loadCurrentSession, loadAllSessions } from './identity.js';
3
6
  import { resolveCrossProjectLinks, loadCrossProjectState } from './cross-project.js';
4
7
  import { buildContextDiff } from './context-diff.js';
5
8
  import { resolveContextStoreCwd, resolveStoreChain } from './store-resolution.js';
@@ -502,6 +505,7 @@ export function buildContext(options = {}) {
502
505
  return undefined;
503
506
  }
504
507
  })(),
508
+ active_project: findActiveProjectInChain(contextCwd, storeChain),
505
509
  cross_project_items: crossProjectItems.length > 0 ? crossProjectItems : undefined,
506
510
  claim_conflicts: detectClaimConflicts(myClaims, otherActiveClaims),
507
511
  workflow_hints: buildWorkflowHints(myClaims, openWork, state.plan_items),
@@ -550,7 +554,43 @@ export function renderContextMarkdown(result, explain = false) {
550
554
  lines.push(`Agent ID: ${result.agent_id}`);
551
555
  }
552
556
  lines.push(`Project mode: ${result.project_mode} (${result.project_strategy})`);
557
+ if (result.active_project) {
558
+ const ap = result.active_project;
559
+ const age = Math.floor((Date.now() - Date.parse(ap.switched_at)) / 3_600_000);
560
+ const sourceHint = ap.source === 'session' ? ', session-scoped' : ', global';
561
+ lines.push(`Active project: ${ap.name ?? ap.path} (switched ${age}h ago by ${ap.switched_by ?? 'unknown'}${sourceHint})`);
562
+ if (ap.source === 'global') {
563
+ lines.push(` ⚠ This is a global switch — all agents on this host see the same project. Use \`brainclaw switch <project>\` during a session for agent-scoped switching.`);
564
+ }
565
+ lines.push(` All commands target this project. Use \`brainclaw switch --clear\` to return to workspace root or \`brainclaw switch <project>\` to change.`);
566
+ }
553
567
  lines.push(`Current host: ${result.current_host}`);
568
+ // Show other active sessions
569
+ try {
570
+ const allSessions = loadAllSessions();
571
+ const ttlMs = 4 * 60 * 60 * 1000;
572
+ const now = Date.now();
573
+ const otherSessions = allSessions.filter(s => s.agent_id !== result.agent_id
574
+ && (now - Date.parse(s.last_seen_at)) <= ttlMs);
575
+ if (otherSessions.length > 0) {
576
+ const summaries = otherSessions.map(s => {
577
+ const proj = s.active_project?.name ?? s.active_project?.path;
578
+ return `${s.user ?? 'unknown'}/${s.agent}${proj ? ` on ${proj}` : ''}`;
579
+ });
580
+ lines.push(`Other active agents: ${summaries.join(', ')}`);
581
+ }
582
+ }
583
+ catch { /* ignore — sessions dir may not exist yet */ }
584
+ // Check for brainclaw update (lightweight local manifest read only)
585
+ try {
586
+ const config = loadConfig();
587
+ const updateCheck = checkBrainclawInstallableUpdate(config, process.cwd());
588
+ const notice = renderBrainclawInstallableUpdateNotice(updateCheck);
589
+ if (notice) {
590
+ lines.push(`⚠ ${notice}`);
591
+ }
592
+ }
593
+ catch { /* ignore — update check is best-effort */ }
554
594
  lines.push(`Memory version: ${result.memory_version}`);
555
595
  lines.push(`Memory density: ${result.memory_density}`);
556
596
  lines.push(`Bootstrap available: ${result.bootstrap_available ? 'yes' : 'no'}`);
@@ -758,6 +798,11 @@ export function renderContextPromptTemplate(result, compact = false) {
758
798
  }
759
799
  lines.push(`project_mode: ${result.project_mode}`);
760
800
  lines.push(`project_strategy: ${result.project_strategy}`);
801
+ if (result.active_project) {
802
+ lines.push(`active_project: ${result.active_project.name ?? result.active_project.path}`);
803
+ lines.push(`active_project_switched: ${result.active_project.switched_at}`);
804
+ lines.push(`active_project_source: ${result.active_project.source ?? 'global'}`);
805
+ }
761
806
  lines.push(`current_host: ${result.current_host}`);
762
807
  lines.push(`memory_version: ${result.memory_version}`);
763
808
  lines.push(`memory_density: ${result.memory_density}`);
@@ -1303,6 +1348,34 @@ function scopesOverlap(a, b) {
1303
1348
  }
1304
1349
  return null;
1305
1350
  }
1351
+ // --- Active project resolution ---
1352
+ function findActiveProjectInChain(contextCwd, _storeChain) {
1353
+ // 1. Session-scoped active project (per-agent, highest priority)
1354
+ const session = loadCurrentSession(contextCwd);
1355
+ if (session?.active_project) {
1356
+ return {
1357
+ path: session.active_project.path,
1358
+ name: session.active_project.name,
1359
+ switched_at: session.active_project.switched_at,
1360
+ switched_by: session.agent,
1361
+ source: 'session',
1362
+ };
1363
+ }
1364
+ // 2. Global active-project.json (walk up from contextCwd)
1365
+ let dir = path.resolve(contextCwd);
1366
+ const root = path.parse(dir).root;
1367
+ const home = process.env.HOME || process.env.USERPROFILE || root;
1368
+ while (dir !== root && dir !== home) {
1369
+ const ap = loadActiveProject(dir);
1370
+ if (ap)
1371
+ return { ...ap, source: 'global' };
1372
+ const parent = path.dirname(dir);
1373
+ if (parent === dir)
1374
+ break;
1375
+ dir = parent;
1376
+ }
1377
+ return undefined;
1378
+ }
1306
1379
  // --- Workflow hints ---
1307
1380
  function buildWorkflowHints(myClaims, openWork, plans) {
1308
1381
  const hints = [];
@@ -12,6 +12,7 @@ export function appendEvent(event, cwd) {
12
12
  ts: event.ts ?? nowISO(),
13
13
  agent: event.agent ?? 'unknown',
14
14
  agent_id: event.agent_id,
15
+ user: event.user ?? process.env.USER ?? process.env.USERNAME,
15
16
  action: event.action,
16
17
  item_type: event.item_type,
17
18
  item_id: event.item_id,
@@ -1,5 +1,6 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { requireRegisteredAgentIdentity } from './agent-registry.js';
5
6
  import { loadConfig } from './config.js';
@@ -7,7 +8,9 @@ import { resolveCurrentHostId } from './host.js';
7
8
  import { memoryDir } from './io.js';
8
9
  import { loadVersionedJsonFile, saveVersionedJsonFile } from './migration.js';
9
10
  import { CurrentSessionStateSchema } from './schema.js';
10
- const CURRENT_SESSION_FILE = '.current-session';
11
+ const SESSIONS_DIR = 'sessions';
12
+ const LEGACY_SESSION_FILE = '.current-session';
13
+ // --- Public API ---
11
14
  export function resolveCurrentSessionId(env = process.env, cwd, options = {}) {
12
15
  const value = env.BRAINCLAW_SESSION_ID?.trim()
13
16
  || env.OPENCLAW_SESSION_ID?.trim()
@@ -64,11 +67,54 @@ export function resolveEventSessionId(event) {
64
67
  ? metadataSession
65
68
  : undefined;
66
69
  }
70
+ /**
71
+ * Load the current session for this agent+user combo.
72
+ * Checks sessions/ directory first, falls back to legacy .current-session.
73
+ */
67
74
  export function loadCurrentSession(cwd) {
68
- const filepath = currentSessionPath(cwd);
69
- if (!fs.existsSync(filepath)) {
70
- return undefined;
75
+ const dir = sessionsDir(cwd);
76
+ const currentUser = resolveCurrentUser();
77
+ const currentAgent = resolveCurrentAgentName();
78
+ // 1. Look in sessions/ directory for a matching session
79
+ if (fs.existsSync(dir)) {
80
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
81
+ const ttlMs = parseDurationToMs(loadConfigSafe(cwd)?.implicit_session_ttl ?? '4h');
82
+ const now = Date.now();
83
+ for (const file of files) {
84
+ try {
85
+ const session = CurrentSessionStateSchema.parse(loadVersionedJsonFile('current_session', path.join(dir, file)).document);
86
+ // Match by agent+user (or agent only if user not set in old sessions)
87
+ const userMatch = !session.user || !currentUser || session.user === currentUser;
88
+ const agentMatch = !currentAgent || session.agent === currentAgent;
89
+ const alive = (now - Date.parse(session.last_seen_at)) <= ttlMs;
90
+ if (userMatch && agentMatch && alive) {
91
+ return session;
92
+ }
93
+ }
94
+ catch {
95
+ // skip invalid session files
96
+ }
97
+ }
71
98
  }
99
+ // 2. Legacy fallback: .current-session
100
+ const legacyPath = path.join(memoryDir(cwd), LEGACY_SESSION_FILE);
101
+ if (fs.existsSync(legacyPath)) {
102
+ try {
103
+ return CurrentSessionStateSchema.parse(loadVersionedJsonFile('current_session', legacyPath).document);
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ }
109
+ return undefined;
110
+ }
111
+ /**
112
+ * Load a specific session by ID.
113
+ */
114
+ export function loadSessionById(sessionId, cwd) {
115
+ const filepath = sessionFilePath(sessionId, cwd);
116
+ if (!fs.existsSync(filepath))
117
+ return undefined;
72
118
  try {
73
119
  return CurrentSessionStateSchema.parse(loadVersionedJsonFile('current_session', filepath).document);
74
120
  }
@@ -76,43 +122,134 @@ export function loadCurrentSession(cwd) {
76
122
  return undefined;
77
123
  }
78
124
  }
125
+ /**
126
+ * Load ALL sessions (active + stale) from the sessions/ directory.
127
+ */
128
+ export function loadAllSessions(cwd) {
129
+ const dir = sessionsDir(cwd);
130
+ if (!fs.existsSync(dir))
131
+ return [];
132
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
133
+ const sessions = [];
134
+ for (const file of files) {
135
+ try {
136
+ sessions.push(CurrentSessionStateSchema.parse(loadVersionedJsonFile('current_session', path.join(dir, file)).document));
137
+ }
138
+ catch {
139
+ // skip invalid
140
+ }
141
+ }
142
+ return sessions.sort((a, b) => b.last_seen_at.localeCompare(a.last_seen_at));
143
+ }
144
+ /**
145
+ * Save a session to the sessions/ directory.
146
+ */
79
147
  export function saveCurrentSession(session, cwd) {
80
- saveVersionedJsonFile('current_session', currentSessionPath(cwd), CurrentSessionStateSchema.parse(session));
148
+ const dir = sessionsDir(cwd);
149
+ if (!fs.existsSync(dir)) {
150
+ fs.mkdirSync(dir, { recursive: true });
151
+ }
152
+ const filepath = sessionFilePath(session.session_id, cwd);
153
+ saveVersionedJsonFile('current_session', filepath, CurrentSessionStateSchema.parse(session));
81
154
  }
155
+ /**
156
+ * Clear a session. If sessionId is provided, only clear that specific session.
157
+ */
82
158
  export function clearCurrentSession(cwd, sessionId) {
83
- const filepath = currentSessionPath(cwd);
84
- if (!fs.existsSync(filepath)) {
159
+ if (sessionId) {
160
+ // Remove specific session file
161
+ const filepath = sessionFilePath(sessionId, cwd);
162
+ try {
163
+ fs.unlinkSync(filepath);
164
+ }
165
+ catch { /* ignore */ }
85
166
  return;
86
167
  }
87
- if (sessionId) {
88
- const current = loadCurrentSession(cwd);
89
- if (!current || current.session_id !== sessionId) {
90
- return;
168
+ // Clear the session for the current agent+user
169
+ const session = loadCurrentSession(cwd);
170
+ if (session) {
171
+ const filepath = sessionFilePath(session.session_id, cwd);
172
+ try {
173
+ fs.unlinkSync(filepath);
91
174
  }
175
+ catch { /* ignore */ }
92
176
  }
177
+ // Also clean legacy file
178
+ const legacyPath = path.join(memoryDir(cwd), LEGACY_SESSION_FILE);
93
179
  try {
94
- fs.unlinkSync(filepath);
180
+ fs.unlinkSync(legacyPath);
95
181
  }
96
- catch {
97
- // Ignore cleanup races.
182
+ catch { /* ignore */ }
183
+ }
184
+ /**
185
+ * Remove stale sessions that have exceeded the TTL.
186
+ * Returns the number of sessions removed.
187
+ */
188
+ export function gcStaleSessions(cwd, ttlOverride) {
189
+ const dir = sessionsDir(cwd);
190
+ if (!fs.existsSync(dir))
191
+ return 0;
192
+ const ttlMs = parseDurationToMs(ttlOverride ?? loadConfigSafe(cwd)?.implicit_session_ttl ?? '4h');
193
+ const now = Date.now();
194
+ let removed = 0;
195
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
196
+ for (const file of files) {
197
+ try {
198
+ const session = CurrentSessionStateSchema.parse(loadVersionedJsonFile('current_session', path.join(dir, file)).document);
199
+ if (now - Date.parse(session.last_seen_at) > ttlMs) {
200
+ fs.unlinkSync(path.join(dir, file));
201
+ removed++;
202
+ }
203
+ }
204
+ catch {
205
+ // Remove unparseable files too
206
+ try {
207
+ fs.unlinkSync(path.join(dir, file));
208
+ removed++;
209
+ }
210
+ catch { /* ignore */ }
211
+ }
98
212
  }
213
+ return removed;
214
+ }
215
+ // --- Internal helpers ---
216
+ function sessionsDir(cwd) {
217
+ return path.join(memoryDir(cwd), SESSIONS_DIR);
218
+ }
219
+ function sessionFilePath(sessionId, cwd) {
220
+ return path.join(sessionsDir(cwd), `${sessionId}.json`);
99
221
  }
100
- function currentSessionPath(cwd) {
101
- return path.join(memoryDir(cwd), CURRENT_SESSION_FILE);
222
+ function resolveCurrentUser() {
223
+ return process.env.USER || process.env.USERNAME || os.userInfo().username || undefined;
224
+ }
225
+ function resolveCurrentAgentName() {
226
+ return process.env.BRAINCLAW_AGENT_NAME || process.env.CLAUDE_CODE_VERSION ? 'claude-code' : undefined;
227
+ }
228
+ function loadConfigSafe(cwd) {
229
+ try {
230
+ return loadConfig(cwd);
231
+ }
232
+ catch {
233
+ return undefined;
234
+ }
102
235
  }
103
236
  function resolveImplicitSession(cwd, options) {
104
237
  const current = loadCurrentSession(cwd);
105
238
  const persistImplicit = options.persistImplicit ?? true;
106
- const ttlMs = parseDurationToMs(loadConfig(cwd).implicit_session_ttl ?? '4h');
239
+ const ttlMs = parseDurationToMs(loadConfigSafe(cwd)?.implicit_session_ttl ?? '4h');
107
240
  const now = new Date();
241
+ const currentUser = resolveCurrentUser();
108
242
  if (current
109
243
  && current.agent === options.agentName
110
244
  && current.agent_id === options.agentId
111
245
  && current.host_id === options.hostId
246
+ && (!current.user || !currentUser || current.user === currentUser)
112
247
  && now.getTime() - Date.parse(current.last_seen_at) <= ttlMs) {
113
248
  const refreshed = {
114
249
  ...current,
115
250
  last_seen_at: now.toISOString(),
251
+ user: current.user || currentUser,
252
+ pid: process.pid,
116
253
  };
117
254
  if (persistImplicit) {
118
255
  saveCurrentSession(refreshed, cwd);
@@ -126,6 +263,8 @@ function resolveImplicitSession(cwd, options) {
126
263
  agent: options.agentName,
127
264
  agent_id: options.agentId,
128
265
  host_id: options.hostId,
266
+ user: currentUser,
267
+ pid: process.pid,
129
268
  };
130
269
  if (persistImplicit) {
131
270
  saveCurrentSession(created, cwd);
@@ -50,6 +50,16 @@ export function analyzeRepository(cwd) {
50
50
  if (matchedDirs.length > 0) {
51
51
  reasons.push(`Found top-level project folders: ${matchedDirs.join(', ')}`);
52
52
  }
53
+ // ── Signal 4: Multiple AGENTS.md files in the tree ──
54
+ const agentsFiles = findNestedAgentsFiles(cwd, 8);
55
+ if (agentsFiles.length > 1) {
56
+ reasons.push(`Found ${agentsFiles.length} AGENTS.md files: ${agentsFiles.slice(0, 5).join(', ')}${agentsFiles.length > 5 ? ` (+${agentsFiles.length - 5} more)` : ''}`);
57
+ }
58
+ // ── Signal 5: Multiple Dockerfiles or docker-compose files (service-oriented workspace) ──
59
+ const dockerComposeFiles = findFilesShallow(cwd, ['docker-compose.yml', 'docker-compose.yaml'], 6);
60
+ if (dockerComposeFiles.length > 1) {
61
+ reasons.push(`Found ${dockerComposeFiles.length} docker-compose files`);
62
+ }
53
63
  const packageJsonPath = path.join(cwd, 'package.json');
54
64
  if (fs.existsSync(packageJsonPath)) {
55
65
  try {
@@ -184,4 +194,61 @@ export function scanWorkspaceBoundaries(rootDir, maxDepth = 3) {
184
194
  walk(rootDir, 1);
185
195
  return { suggestions, alreadyInitialised };
186
196
  }
197
+ /**
198
+ * Find AGENTS.md files up to maxDepth levels deep.
199
+ * Returns relative paths from cwd.
200
+ */
201
+ export function findNestedAgentsFiles(cwd, maxDepth) {
202
+ const results = [];
203
+ function walk(dir, depth) {
204
+ if (depth > maxDepth || results.length > 10)
205
+ return;
206
+ let entries;
207
+ try {
208
+ entries = fs.readdirSync(dir, { withFileTypes: true });
209
+ }
210
+ catch {
211
+ return;
212
+ }
213
+ for (const entry of entries) {
214
+ if (entry.isFile() && entry.name === 'AGENTS.md') {
215
+ results.push(path.relative(cwd, path.join(dir, entry.name)));
216
+ }
217
+ if (entry.isDirectory() && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
218
+ walk(path.join(dir, entry.name), depth + 1);
219
+ }
220
+ }
221
+ }
222
+ walk(cwd, 0);
223
+ return results;
224
+ }
225
+ /**
226
+ * Find specific filenames up to maxDepth levels deep.
227
+ * Returns relative paths from cwd.
228
+ */
229
+ function findFilesShallow(cwd, filenames, maxDepth) {
230
+ const nameSet = new Set(filenames);
231
+ const results = [];
232
+ function walk(dir, depth) {
233
+ if (depth > maxDepth || results.length > 10)
234
+ return;
235
+ let entries;
236
+ try {
237
+ entries = fs.readdirSync(dir, { withFileTypes: true });
238
+ }
239
+ catch {
240
+ return;
241
+ }
242
+ for (const entry of entries) {
243
+ if (entry.isFile() && nameSet.has(entry.name)) {
244
+ results.push(path.relative(cwd, path.join(dir, entry.name)));
245
+ }
246
+ if (entry.isDirectory() && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
247
+ walk(path.join(dir, entry.name), depth + 1);
248
+ }
249
+ }
250
+ }
251
+ walk(cwd, 0);
252
+ return results;
253
+ }
187
254
  //# sourceMappingURL=repo-analysis.js.map
@@ -33,6 +33,30 @@ function coerceEffortToMinutes(val) {
33
33
  }
34
34
  return undefined;
35
35
  }
36
+ /** Coerce tags from JSON string to array when MCP clients serialize arrays as strings.
37
+ * Accepts: string[] (passthrough), '["a","b"]' (JSON parse), 'a,b' (comma split). */
38
+ function coerceTags(val) {
39
+ if (Array.isArray(val))
40
+ return val;
41
+ if (typeof val === 'string') {
42
+ const trimmed = val.trim();
43
+ if (trimmed.startsWith('[')) {
44
+ try {
45
+ const parsed = JSON.parse(trimmed);
46
+ if (Array.isArray(parsed))
47
+ return parsed;
48
+ }
49
+ catch { /* fall through */ }
50
+ }
51
+ if (trimmed.length > 0)
52
+ return trimmed.split(',').map(t => t.trim()).filter(Boolean);
53
+ return [];
54
+ }
55
+ return val;
56
+ }
57
+ /** Resilient tags schema that accepts string[] or JSON-serialized string. */
58
+ export const TagsSchema = z.preprocess(coerceTags, z.array(z.string()));
59
+ export const TagsWithDefaultSchema = z.preprocess(coerceTags, z.array(z.string()).default([]));
36
60
  // --- Entry schemas ---
37
61
  export const ConstraintStatusSchema = z.enum(['active', 'resolved', 'expired']);
38
62
  export const ConstraintCategorySchema = z.enum(['architecture', 'performance', 'security', 'reliability', 'compatibility', 'process', 'other']);
@@ -58,7 +82,7 @@ export const ConstraintSchema = z.object({
58
82
  status: ConstraintStatusSchema,
59
83
  category: ConstraintCategorySchema.optional(),
60
84
  scope: MemoryScopeSchema.optional(),
61
- tags: z.array(z.string()),
85
+ tags: TagsSchema,
62
86
  related_paths: z.array(z.string()).optional(),
63
87
  expires_at: z.string().optional(),
64
88
  });
@@ -78,7 +102,7 @@ export const DecisionSchema = z.object({
78
102
  scope: MemoryScopeSchema.optional(),
79
103
  related_paths: z.array(z.string()).optional(),
80
104
  plan_id: z.string().optional(),
81
- tags: z.array(z.string()),
105
+ tags: TagsSchema,
82
106
  });
83
107
  export const TrapSchema = z.object({
84
108
  schema_version: z.number().int().positive().optional(),
@@ -94,7 +118,7 @@ export const TrapSchema = z.object({
94
118
  status: TrapStatusSchema.default('active'),
95
119
  severity: SeveritySchema,
96
120
  scope: MemoryScopeSchema.optional(),
97
- tags: z.array(z.string()),
121
+ tags: TagsSchema,
98
122
  related_paths: z.array(z.string()).optional(),
99
123
  plan_id: z.string().optional(),
100
124
  visibility: MemoryVisibilitySchema.default('shared'),
@@ -119,7 +143,7 @@ export const HandoffSchema = z.object({
119
143
  status: HandoffStatusSchema,
120
144
  project: z.string().optional(),
121
145
  plan_id: z.string().optional(),
122
- tags: z.array(z.string()),
146
+ tags: TagsSchema,
123
147
  related_paths: z.array(z.string()).optional(),
124
148
  snapshot: z.object({
125
149
  diff: z.string().optional(),
@@ -150,7 +174,7 @@ export const PlanItemSchema = z.object({
150
174
  priority: PrioritySchema,
151
175
  assignee: z.string().optional(),
152
176
  project: z.string().optional(),
153
- tags: z.array(z.string()),
177
+ tags: TagsSchema,
154
178
  related_paths: z.array(z.string()).optional(),
155
179
  depends_on: z.array(z.string()).default([]),
156
180
  steps: z.array(PlanStepSchema).optional(),
@@ -170,7 +194,7 @@ export const InstructionEntrySchema = z.object({
170
194
  updated_at: z.string(),
171
195
  author: z.string(),
172
196
  model: z.string().optional(),
173
- tags: z.array(z.string()).default([]),
197
+ tags: TagsWithDefaultSchema,
174
198
  active: z.boolean().default(true),
175
199
  supersedes: z.string().optional(),
176
200
  });
@@ -183,7 +207,7 @@ export const ProjectCapabilitySchema = z.object({
183
207
  category: z.string(), // e.g. "auth", "api", "storage", "testing"
184
208
  provided_by: z.string().optional(), // path to implementation
185
209
  requires: z.array(z.string()).optional(), // capability IDs this depends on
186
- tags: z.array(z.string()),
210
+ tags: TagsSchema,
187
211
  example_usage: z.string().optional(),
188
212
  status: CapabilityStatusSchema.default('stable'),
189
213
  related_paths: z.array(z.string()).optional(),
@@ -205,7 +229,7 @@ export const ProjectToolSchema = z.object({
205
229
  requires: z.array(z.string()).optional(), // tool IDs this depends on
206
230
  suggests_for: z.array(z.string()).optional(), // agent types or domains
207
231
  invocation_example: z.string().optional(),
208
- tags: z.array(z.string()),
232
+ tags: TagsSchema,
209
233
  status: CapabilityStatusSchema.default('stable'),
210
234
  related_paths: z.array(z.string()).optional(),
211
235
  created_at: z.string(),
@@ -271,7 +295,7 @@ export const CandidateSchema = z.object({
271
295
  host_id: z.string().optional(),
272
296
  session_id: z.string().optional(),
273
297
  source: z.string().optional(),
274
- tags: z.array(z.string()),
298
+ tags: TagsSchema,
275
299
  status: CandidateStatusSchema,
276
300
  // type-specific optional fields
277
301
  severity: SeveritySchema.optional(),
@@ -324,6 +348,8 @@ export const ClaimSchema = z.object({
324
348
  id: z.string(),
325
349
  agent: z.string(),
326
350
  agent_id: z.string().optional(),
351
+ /** OS user who created this claim. */
352
+ user: z.string().optional(),
327
353
  project_id: z.string().optional(),
328
354
  host_id: z.string().optional(),
329
355
  session_id: z.string().optional(),
@@ -349,7 +375,7 @@ export const RuntimeNoteSchema = z.object({
349
375
  created_at: z.string(),
350
376
  project: z.string().optional(),
351
377
  plan_id: z.string().optional(),
352
- tags: z.array(z.string()),
378
+ tags: TagsSchema,
353
379
  visibility: MemoryVisibilitySchema.default('shared'),
354
380
  host_id: z.string().optional(),
355
381
  expires_at: z.string().optional(),
@@ -376,7 +402,7 @@ export const AiSurfaceTaskRequestSchema = z.object({
376
402
  status: AiSurfaceTaskStatusSchema.default('queued'),
377
403
  requested_outputs: z.array(z.string()).default([]),
378
404
  related_paths: z.array(z.string()).optional(),
379
- tags: z.array(z.string()).default([]),
405
+ tags: TagsWithDefaultSchema,
380
406
  claimed_at: z.string().optional(),
381
407
  completed_at: z.string().optional(),
382
408
  result_note: z.string().optional(),
@@ -402,7 +428,7 @@ export const RuntimeEventSchema = z.object({
402
428
  event_type: RuntimeEventTypeSchema,
403
429
  created_at: z.string(),
404
430
  text: z.string(),
405
- tags: z.array(z.string()).default([]),
431
+ tags: TagsWithDefaultSchema,
406
432
  // Optional routing and type hints for candidate generation
407
433
  candidate_type: CandidateTypeSchema.optional(),
408
434
  severity: SeveritySchema.optional(),
@@ -468,6 +494,14 @@ export const SessionSnapshotSchema = z.object({
468
494
  initial_context_hash: z.string().optional(),
469
495
  git_sha: z.string().optional(),
470
496
  });
497
+ export const SessionActiveProjectSchema = z.object({
498
+ /** Absolute path to the project directory. */
499
+ path: z.string(),
500
+ /** Project name from config.yaml (when available). */
501
+ name: z.string().optional(),
502
+ /** ISO timestamp of the switch. */
503
+ switched_at: z.string(),
504
+ }).strict();
471
505
  export const CurrentSessionStateSchema = z.object({
472
506
  schema_version: z.number().int().positive().optional(),
473
507
  session_id: z.string(),
@@ -476,6 +510,12 @@ export const CurrentSessionStateSchema = z.object({
476
510
  agent: z.string(),
477
511
  agent_id: z.string(),
478
512
  host_id: z.string(),
513
+ /** OS user who started this session. */
514
+ user: z.string().optional(),
515
+ /** Process ID of the agent process (for liveness detection). */
516
+ pid: z.number().int().positive().optional(),
517
+ /** Session-scoped active project (overrides global active-project.json). */
518
+ active_project: SessionActiveProjectSchema.optional(),
479
519
  });
480
520
  export const MemorySeedKindSchema = z.enum([
481
521
  'command',
@@ -516,7 +556,7 @@ export const MemorySeedDocumentSchema = z.object({
516
556
  source_ref: z.string(),
517
557
  confidence: MemorySeedConfidenceSchema,
518
558
  related_paths: z.array(z.string()).optional(),
519
- tags: z.array(z.string()).default([]),
559
+ tags: TagsWithDefaultSchema,
520
560
  promotion_hint: z.enum(['constraint', 'decision', 'trap']).optional(),
521
561
  });
522
562
  export const BootstrapProfileDocumentSchema = z.object({
@@ -547,7 +587,7 @@ export const BootstrapSuggestionDocumentSchema = z.object({
547
587
  source_refs: z.array(z.string()).default([]),
548
588
  layer: z.enum(['global', 'project', 'agent']).optional(),
549
589
  scope: z.string().optional(),
550
- tags: z.array(z.string()).default([]),
590
+ tags: TagsWithDefaultSchema,
551
591
  related_paths: z.array(z.string()).optional(),
552
592
  category: ConstraintCategorySchema.optional(),
553
593
  outcome: DecisionOutcomeSchema.optional(),
@@ -581,7 +621,7 @@ export const BootstrapInterviewAnswerSuggestionSchema = z.object({
581
621
  confidence: MemorySeedConfidenceSchema.optional(),
582
622
  layer: z.enum(['global', 'project', 'agent']).optional(),
583
623
  scope: z.string().optional(),
584
- tags: z.array(z.string()).default([]),
624
+ tags: TagsWithDefaultSchema,
585
625
  related_paths: z.array(z.string()).optional(),
586
626
  category: ConstraintCategorySchema.optional(),
587
627
  outcome: DecisionOutcomeSchema.optional(),
@@ -3,6 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { loadActiveProject } from './active-project.js';
5
5
  import { loadConfig } from './config.js';
6
+ import { loadCurrentSession } from './identity.js';
6
7
  import { MEMORY_DIR } from './io.js';
7
8
  import { summarizeWorkspaceProjects } from './workspace-projects.js';
8
9
  /**
@@ -91,8 +92,9 @@ export function resolveTargetStore(cwd = process.cwd(), target = 'local', option
91
92
  * Priority:
92
93
  * 1. explicitCwd (--cwd flag)
93
94
  * 2. BRAINCLAW_PROJECT env var → resolved by name/path from workspace
94
- * 3. active-project.json in workspace root
95
- * 4. process.cwd()
95
+ * 3. Session-scoped active project (from .current-session)
96
+ * 4. Global active-project.json in workspace root
97
+ * 5. process.cwd()
96
98
  */
97
99
  export function resolveEffectiveCwd(options = {}) {
98
100
  // 1. Explicit --cwd flag
@@ -106,7 +108,15 @@ export function resolveEffectiveCwd(options = {}) {
106
108
  if (resolved)
107
109
  return resolved;
108
110
  }
109
- // 3. active-project.json from workspace root
111
+ // 3. Session-scoped active project (per-agent, no cross-agent interference)
112
+ const session = loadCurrentSession(process.cwd());
113
+ if (session?.active_project) {
114
+ const sp = session.active_project;
115
+ if (fs.existsSync(path.join(sp.path, MEMORY_DIR, 'config.yaml'))) {
116
+ return sp.path;
117
+ }
118
+ }
119
+ // 4. Global active-project.json from workspace root
110
120
  const wsRoot = resolveWorkspaceRoot(process.cwd(), options.storeChainOptions);
111
121
  if (wsRoot) {
112
122
  const active = loadActiveProject(wsRoot);
@@ -114,7 +124,7 @@ export function resolveEffectiveCwd(options = {}) {
114
124
  return active.path;
115
125
  }
116
126
  }
117
- // 4. Default
127
+ // 5. Default
118
128
  return process.cwd();
119
129
  }
120
130
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainclaw",
3
- "version": "0.28.0",
3
+ "version": "0.29.2",
4
4
  "description": "Shared project memory for humans and coding agents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,7 +45,7 @@
45
45
  "license": "BUSL-1.1",
46
46
  "homepage": "https://brainclaw.dev",
47
47
  "engines": {
48
- "node": ">=20"
48
+ "node": ">=18.0.0"
49
49
  },
50
50
  "dependencies": {
51
51
  "commander": "^13.1.0",