dual-brain 7.1.6 → 7.1.7

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/install.mjs CHANGED
@@ -1463,6 +1463,36 @@ async function main() {
1463
1463
  }
1464
1464
 
1465
1465
  const actions = install(env.workspace, env, mode);
1466
+
1467
+ // Write a standalone shell-hook.sh so users can source it from .bashrc.
1468
+ // Non-interactive installs (npm postinstall) just print the hint; interactive
1469
+ // installs also write the file so it's ready to source.
1470
+ const shellHookSrc = join(__dirname, 'shell-hook.sh');
1471
+ const shellHookDst = join(env.workspace, '.dualbrain', 'shell-hook.sh');
1472
+ try {
1473
+ mkdirSync(join(env.workspace, '.dualbrain'), { recursive: true });
1474
+ if (existsSync(shellHookSrc)) {
1475
+ cpSync(shellHookSrc, shellHookDst);
1476
+ actions.push('✓ .dualbrain/shell-hook.sh (source from .bashrc to auto-launch)');
1477
+ }
1478
+ } catch { /* non-fatal — shell hook is optional */ }
1479
+
1480
+ // On Replit, print a one-liner hint for the shell hook if .bashrc doesn't have it yet.
1481
+ if (env.isReplit) {
1482
+ let bashrcHasDualBrain = false;
1483
+ const bashrcPath = join(process.env.HOME || '', '.bashrc');
1484
+ try {
1485
+ bashrcHasDualBrain = readFileSync(bashrcPath, 'utf8').includes('dual-brain');
1486
+ } catch { /* .bashrc may not exist */ }
1487
+
1488
+ if (!bashrcHasDualBrain) {
1489
+ actions.push('');
1490
+ actions.push('Shell hook (optional — shows dual-brain on new terminal):');
1491
+ actions.push(' dual-brain shell-hook >> ~/.bashrc');
1492
+ actions.push(' # or: source .dualbrain/shell-hook.sh');
1493
+ }
1494
+ }
1495
+
1466
1496
  printReport(env, mode, actions);
1467
1497
 
1468
1498
  // After install, launch the session manager (interactive TTY only)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "7.1.6",
3
+ "version": "7.1.7",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
@@ -103,6 +103,7 @@
103
103
  "playbooks/*.json",
104
104
  "plugin.json",
105
105
  "skills/*.md",
106
- "agents/*.md"
106
+ "agents/*.md",
107
+ "shell-hook.sh"
107
108
  ]
108
109
  }
package/shell-hook.sh ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+ # dual-brain shell integration
3
+ # Add to .bashrc with one command:
4
+ # dual-brain shell-hook >> ~/.bashrc
5
+ # Or source directly:
6
+ # source /path/to/shell-hook.sh
7
+
8
+ # Quick alias
9
+ alias db='dual-brain'
10
+
11
+ # Show session manager on new interactive terminal.
12
+ # Skipped when:
13
+ # - not a TTY (non-interactive shell, CI, pipes)
14
+ # - DUAL_BRAIN_LOADED already set (prevents double-launch in nested shells)
15
+ # - DUAL_BRAIN_SKIP=1 (user opt-out)
16
+ # - DATA_TOOLS_LOADED or CLAUDE_MENU_LOADED is set (data-tools is managing the shell)
17
+ if [ -t 1 ] \
18
+ && [ -z "$DUAL_BRAIN_LOADED" ] \
19
+ && [ -z "$DUAL_BRAIN_SKIP" ] \
20
+ && [ -z "$DATA_TOOLS_LOADED" ] \
21
+ && [ -z "$CLAUDE_MENU_LOADED" ]; then
22
+ export DUAL_BRAIN_LOADED=1
23
+ if command -v dual-brain &>/dev/null; then
24
+ dual-brain
25
+ fi
26
+ fi
package/src/index.mjs CHANGED
@@ -6,14 +6,14 @@
6
6
  * orchestrate() convenience function for programmatic use.
7
7
  */
8
8
 
9
- export { loadProfile, saveProfile, ensureProfile, runOnboarding, rememberPreference, forgetPreference, getActivePreferences, getAvailableProviders, isSoloBrain, getHeadModel, detectAuth, detectEnvironment, setupAuth, getActiveKey } from './profile.mjs';
9
+ export { loadProfile, saveProfile, ensureProfile, runOnboarding, rememberPreference, forgetPreference, getActivePreferences, getAvailableProviders, isSoloBrain, getHeadModel, detectAuth, detectEnvironment, saveSubscription, listSubscriptions } from './profile.mjs';
10
10
  export { detectTask, classifyIntent, classifyRisk, estimateComplexity, inferTier, extractPaths } from './detect.mjs';
11
11
  export { decideRoute, getModelCapabilities, getAvailableModels, shouldDualBrain, explainDecision } from './decide.mjs';
12
12
  export { dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrain } from './dispatch.mjs';
13
13
  export { loadPlaybook, listPlaybooks, executePlaybook, createRunArtifact } from './playbook.mjs';
14
14
  export { getHealth, markHot, markDegraded, markHealthy, checkCooldown, getProviderScore, recordDispatch, getSessionStats, resetHealth, remainingCooldownMinutes } from './health.mjs';
15
15
  export { detectRepo, loadRepoCache, getTestCommand, getLintCommand } from './repo.mjs';
16
- export { loadSession, saveSession, updateSession, clearSession, formatSessionCard } from './session.mjs';
16
+ export { loadSession, saveSession, updateSession, clearSession, formatSessionCard, importReplitSessions, renameSession, pinSession, unpinSession, categorizeSession, getSessionMeta, autoLabel, enrichSessions } from './session.mjs';
17
17
  export { decompose, isSimpleTask, taskGraphToWaves } from './decompose.mjs';
18
18
  export { generateBrief, compressPriorResults, listRoles } from './brief.mjs';
19
19
  export { redact, redactFiles, isSecretFile } from './redact.mjs';
package/src/profile.mjs CHANGED
@@ -23,9 +23,9 @@
23
23
  */
24
24
 
25
25
  import { createInterface } from 'readline';
26
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
26
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
27
27
  import { homedir } from 'os';
28
- import { dirname, join } from 'path';
28
+ import { join } from 'path';
29
29
 
30
30
  // ---------------------------------------------------------------------------
31
31
  // Claude Code memory integration
@@ -126,31 +126,19 @@ function detectEnvironment() {
126
126
  // ---------------------------------------------------------------------------
127
127
 
128
128
  /**
129
- * Mask a credential string: show first 4 + "..." + last 4 chars.
130
- * For short strings (< 8 chars), just returns "***".
131
- * @param {string} str
132
- * @returns {string}
133
- */
134
- function _maskCredential(str) {
135
- if (!str || str.length < 8) return '***';
136
- return str.slice(0, 4) + '...' + str.slice(-4);
137
- }
138
-
139
- /**
140
- * Detect authentication credentials from all known sources.
141
- * Checks in priority order: config files first, then env vars.
142
- * Never makes network calls — validation is always null in v1.
129
+ * Detect CLI login status for Claude and Codex.
130
+ * Checks config files on disk never makes network calls.
143
131
  *
144
132
  * @returns {{ claude: AuthEntry, openai: AuthEntry }}
145
- * @typedef {{ found: boolean, source: string|null, masked: string|null, validated: null }} AuthEntry
133
+ * @typedef {{ found: boolean, source: string|null, loginType: 'oauth'|'cli'|null }} AuthEntry
146
134
  */
147
135
  async function detectAuth() {
148
136
  const results = {
149
- claude: { found: false, source: null, masked: null, validated: null },
150
- openai: { found: false, source: null, masked: null, validated: null },
137
+ claude: { found: false, source: null, loginType: null },
138
+ openai: { found: false, source: null, loginType: null },
151
139
  };
152
140
 
153
- // --- Claude: check .claude.json for oauthAccount or apiKey ---
141
+ // --- Claude: check .claude.json for oauthAccount (CLI login) ---
154
142
  const claudePaths = [
155
143
  '/home/runner/workspace/.replit-tools/.claude-persistent/.claude.json',
156
144
  join(homedir(), '.claude', '.claude.json'),
@@ -159,51 +147,22 @@ async function detectAuth() {
159
147
  try {
160
148
  const data = JSON.parse(readFileSync(p, 'utf8'));
161
149
  if (data?.oauthAccount) {
162
- // OAuth session found
163
- results.claude.found = true;
164
- results.claude.source = p.includes('.replit-tools') ? '.claude.json (replit-tools)' : '.claude.json';
165
- results.claude.masked = 'oauth:configured';
150
+ results.claude.found = true;
151
+ results.claude.source = p.includes('.replit-tools') ? 'claude CLI (replit-tools)' : 'claude CLI';
152
+ results.claude.loginType = 'oauth';
166
153
  break;
167
154
  }
155
+ // Legacy: apiKey field in .claude.json (set by claude CLI in some versions)
168
156
  if (data?.apiKey && typeof data.apiKey === 'string') {
169
- results.claude.found = true;
170
- results.claude.source = p.includes('.replit-tools') ? '.claude.json (replit-tools)' : '.claude.json';
171
- results.claude.masked = _maskCredential(data.apiKey);
157
+ results.claude.found = true;
158
+ results.claude.source = p.includes('.replit-tools') ? 'claude CLI (replit-tools)' : 'claude CLI';
159
+ results.claude.loginType = 'cli';
172
160
  break;
173
161
  }
174
162
  } catch { continue; }
175
163
  }
176
164
 
177
- // --- Claude: check .dualbrain/auth.json (before env var) ---
178
- if (!results.claude.found) {
179
- const storedAuth = loadAuthKeys();
180
- const claudeKeys = storedAuth.claude || [];
181
- const activeClaudeKey = getActiveKey('claude');
182
- if (activeClaudeKey) {
183
- results.claude.found = true;
184
- results.claude.source = '.dualbrain/auth.json';
185
- results.claude.masked = _maskCredential(activeClaudeKey.key);
186
- process.env.ANTHROPIC_API_KEY = activeClaudeKey.key;
187
- // Report all keys with masked values
188
- const now = new Date();
189
- results.claude.keys = claudeKeys.map(k => ({
190
- label: k.label || 'unlabeled',
191
- masked: _maskCredential(k.key),
192
- priority: k.priority || 99,
193
- enabled: k.enabled !== false,
194
- expired: !!(k.expiresAt && new Date(k.expiresAt) <= now),
195
- }));
196
- }
197
- }
198
-
199
- // --- Claude: fallback to ANTHROPIC_API_KEY env var ---
200
- if (!results.claude.found && process.env.ANTHROPIC_API_KEY) {
201
- results.claude.found = true;
202
- results.claude.source = 'env:ANTHROPIC_API_KEY';
203
- results.claude.masked = _maskCredential(process.env.ANTHROPIC_API_KEY);
204
- }
205
-
206
- // --- OpenAI/Codex: check auth.json for access_token or id_token ---
165
+ // --- OpenAI/Codex: check auth.json for access_token or id_token (CLI login) ---
207
166
  const codexPaths = [
208
167
  '/home/runner/workspace/.replit-tools/.codex-persistent/auth.json',
209
168
  join(homedir(), '.codex', 'auth.json'),
@@ -213,208 +172,48 @@ async function detectAuth() {
213
172
  const data = JSON.parse(readFileSync(p, 'utf8'));
214
173
  const accessToken = data?.tokens?.access_token || data?.access_token;
215
174
  const idToken = data?.tokens?.id_token || data?.id_token;
216
- const apiKey = data?.apiKey ?? data?.api_key ?? null;
217
175
 
218
176
  if (accessToken || idToken) {
219
- results.openai.found = true;
220
- results.openai.source = p.includes('.replit-tools') ? 'codex auth.json (replit-tools)' : 'codex auth.json';
221
- results.openai.masked = 'oauth:configured';
222
- break;
223
- }
224
- if (apiKey && typeof apiKey === 'string') {
225
- results.openai.found = true;
226
- results.openai.source = p.includes('.replit-tools') ? 'codex auth.json (replit-tools)' : 'codex auth.json';
227
- results.openai.masked = _maskCredential(apiKey);
177
+ results.openai.found = true;
178
+ results.openai.source = p.includes('.replit-tools') ? 'codex CLI (replit-tools)' : 'codex CLI';
179
+ results.openai.loginType = 'oauth';
228
180
  break;
229
181
  }
230
182
  } catch { continue; }
231
183
  }
232
184
 
233
- // --- OpenAI: check .dualbrain/auth.json (before env var) ---
234
- if (!results.openai.found) {
235
- const storedAuth = loadAuthKeys();
236
- const openaiKeys = storedAuth.openai || [];
237
- const activeOpenaiKey = getActiveKey('openai');
238
- if (activeOpenaiKey) {
239
- results.openai.found = true;
240
- results.openai.source = '.dualbrain/auth.json';
241
- results.openai.masked = _maskCredential(activeOpenaiKey.key);
242
- process.env.OPENAI_API_KEY = activeOpenaiKey.key;
243
- // Report all keys with masked values
244
- const now = new Date();
245
- results.openai.keys = openaiKeys.map(k => ({
246
- label: k.label || 'unlabeled',
247
- masked: _maskCredential(k.key),
248
- priority: k.priority || 99,
249
- enabled: k.enabled !== false,
250
- expired: !!(k.expiresAt && new Date(k.expiresAt) <= now),
251
- }));
252
- }
253
- }
254
-
255
- // --- OpenAI: fallback to OPENAI_API_KEY env var ---
256
- if (!results.openai.found && process.env.OPENAI_API_KEY) {
257
- results.openai.found = true;
258
- results.openai.source = 'env:OPENAI_API_KEY';
259
- results.openai.masked = _maskCredential(process.env.OPENAI_API_KEY);
260
- }
261
-
262
185
  return results;
263
186
  }
264
187
 
265
188
  // ---------------------------------------------------------------------------
266
- // API key storage (.dualbrain/auth.json)
189
+ // Subscription management (.dualbrain/profile.json)
267
190
  // ---------------------------------------------------------------------------
268
191
 
269
- const AUTH_FILE = (cwd) => join(cwd || process.cwd(), '.dualbrain', 'auth.json');
270
-
271
192
  /**
272
- * Load .dualbrain/auth.json.
193
+ * Save subscription config for a provider into .dualbrain/profile.json.
194
+ * @param {string} provider — 'claude' or 'openai'
195
+ * @param {{ plan: string, label?: string, expiresAt?: string }} config
273
196
  * @param {string} [cwd]
274
- * @returns {object} auth object with arrays per provider
275
197
  */
276
- function loadAuthKeys(cwd) {
277
- try {
278
- return JSON.parse(readFileSync(AUTH_FILE(cwd), 'utf8'));
279
- } catch {
280
- return {};
281
- }
198
+ function saveSubscription(provider, config, cwd) {
199
+ const profile = loadProfile(cwd);
200
+ if (!profile.providers[provider]) profile.providers[provider] = { enabled: true };
201
+ profile.providers[provider].plan = config.plan;
202
+ profile.providers[provider].enabled = true;
203
+ if (config.label) profile.providers[provider].label = config.label;
204
+ if (config.expiresAt) profile.providers[provider].expiresAt = config.expiresAt;
205
+ saveProfile(profile, { cwd: cwd || process.cwd() });
206
+ return profile;
282
207
  }
283
208
 
284
209
  /**
285
- * Returns the highest-priority, non-expired, enabled key for a provider.
286
- * @param {string} provider
210
+ * Return subscription configs for all providers from the saved profile.
287
211
  * @param {string} [cwd]
288
- * @returns {{ key: string, label: string, priority: number, enabled: boolean, expiresAt: string|null }|null}
289
- */
290
- function getActiveKey(provider, cwd) {
291
- const auth = loadAuthKeys(cwd);
292
- const keys = auth[provider] || [];
293
- const now = new Date();
294
-
295
- const valid = keys
296
- .filter(k => k.enabled)
297
- .filter(k => !k.expiresAt || new Date(k.expiresAt) > now)
298
- .sort((a, b) => (a.priority || 99) - (b.priority || 99));
299
-
300
- return valid[0] || null;
301
- }
302
-
303
- /**
304
- * Append a new key to the provider's array in .dualbrain/auth.json.
305
- * Injects the highest-priority valid key into process.env.
306
- * @param {string} provider
307
- * @param {string} key
308
- * @param {object} [opts]
309
- * @param {string} [opts.label]
310
- * @param {string|null} [opts.expiresAt]
311
- * @param {number} [opts.priority]
312
- * @param {string} [opts.cwd]
313
- */
314
- function saveAuthKey(provider, key, opts = {}) {
315
- const cwd = opts.cwd || process.cwd();
316
- const authFile = AUTH_FILE(cwd);
317
- const dir = dirname(authFile);
318
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
319
-
320
- const auth = loadAuthKeys(cwd);
321
- if (!Array.isArray(auth[provider])) auth[provider] = [];
322
-
323
- // Determine default label and priority
324
- const existing = auth[provider];
325
- const defaultLabel = `key-${existing.length + 1}`;
326
- const defaultPriority = existing.length > 0
327
- ? Math.max(...existing.map(k => k.priority || 1)) + 1
328
- : 1;
329
-
330
- existing.push({
331
- key,
332
- label: opts.label || defaultLabel,
333
- savedAt: new Date().toISOString(),
334
- expiresAt: opts.expiresAt || null,
335
- priority: opts.priority !== undefined ? opts.priority : defaultPriority,
336
- enabled: true,
337
- });
338
-
339
- writeFileSync(authFile, JSON.stringify(auth, null, 2));
340
- chmodSync(authFile, 0o600);
341
-
342
- // Inject highest-priority valid key into process.env for this session
343
- const active = getActiveKey(provider, cwd);
344
- if (active) {
345
- if (provider === 'claude') process.env.ANTHROPIC_API_KEY = active.key;
346
- if (provider === 'openai') process.env.OPENAI_API_KEY = active.key;
347
- }
348
- }
349
-
350
- /**
351
- * Interactive setup flow: walks user through entering API keys for missing providers.
352
- * Accepts an existing readline Interface (rl) — does NOT close it.
353
- * @param {import('readline').Interface} rl
212
+ * @returns {{ [provider: string]: { plan: string, enabled: boolean, label?: string, expiresAt?: string } }}
354
213
  */
355
- async function setupAuth(rl) {
356
- const ask = (q) => new Promise(res => rl.question(q, res));
357
- const auth = await detectAuth();
358
-
359
- // Claude setup
360
- if (!auth.claude.found) {
361
- console.log('\n— Claude Setup —');
362
- console.log('Options:');
363
- console.log(' (1) Paste API key (recommended for Replit)');
364
- console.log(' (2) Skip for now');
365
- const choice = (await ask('> ')).trim();
366
- if (choice === '1') {
367
- const key = (await ask('Paste your Anthropic API key: ')).trim();
368
- if (key && (key.startsWith('sk-ant-') || key.startsWith('sk-'))) {
369
- const labelStr = (await ask('Label for this key (or Enter for "key-1"): ')).trim();
370
- const label = labelStr || undefined;
371
- const expiryStr = (await ask('Set expiry in days (or Enter to skip): ')).trim();
372
- let expiresAt = null;
373
- if (expiryStr && /^\d+$/.test(expiryStr)) {
374
- const d = new Date();
375
- d.setDate(d.getDate() + parseInt(expiryStr, 10));
376
- expiresAt = d.toISOString();
377
- console.log(`✓ Key expires in ${expiryStr} days (${d.toISOString().slice(0, 10)})`);
378
- }
379
- saveAuthKey('claude', key, { expiresAt, label });
380
- console.log('✓ Claude API key saved');
381
- } else {
382
- console.log('Invalid key format. Expected sk-ant-... or sk-...');
383
- }
384
- }
385
- } else {
386
- console.log(`\n✓ Claude: already configured via ${auth.claude.source}`);
387
- }
388
-
389
- // OpenAI setup
390
- if (!auth.openai.found) {
391
- console.log('\n— OpenAI Setup —');
392
- console.log('Options:');
393
- console.log(' (1) Paste API key (recommended for Replit)');
394
- console.log(' (2) Skip for now');
395
- const choice = (await ask('> ')).trim();
396
- if (choice === '1') {
397
- const key = (await ask('Paste your OpenAI API key: ')).trim();
398
- if (key && key.startsWith('sk-')) {
399
- const labelStr = (await ask('Label for this key (or Enter for "key-1"): ')).trim();
400
- const label = labelStr || undefined;
401
- const expiryStr = (await ask('Set expiry in days (or Enter to skip): ')).trim();
402
- let expiresAt = null;
403
- if (expiryStr && /^\d+$/.test(expiryStr)) {
404
- const d = new Date();
405
- d.setDate(d.getDate() + parseInt(expiryStr, 10));
406
- expiresAt = d.toISOString();
407
- console.log(`✓ Key expires in ${expiryStr} days (${d.toISOString().slice(0, 10)})`);
408
- }
409
- saveAuthKey('openai', key, { expiresAt, label });
410
- console.log('✓ OpenAI API key saved');
411
- } else {
412
- console.log('Invalid key format. Expected sk-...');
413
- }
414
- }
415
- } else {
416
- console.log(`\n✓ OpenAI: already configured via ${auth.openai.source}`);
417
- }
214
+ function listSubscriptions(cwd) {
215
+ const profile = loadProfile(cwd);
216
+ return profile.providers || {};
418
217
  }
419
218
 
420
219
  // ---------------------------------------------------------------------------
@@ -827,10 +626,10 @@ async function autoSetup(cwd) {
827
626
  '$100': 'Claude Max x5 ($100)',
828
627
  '$200': 'Claude Max x20 ($200)',
829
628
  }[profile.providers.claude.plan] || profile.providers.claude.plan;
830
- result.actions.push(`${planLabel} via ${auth.claude.source}`);
629
+ result.actions.push(`${planLabel} (${auth.claude.source})`);
831
630
  } else {
832
631
  profile.providers.claude.enabled = false;
833
- result.warnings.push('Claude not authenticated');
632
+ result.warnings.push('Claude CLI not logged in — run: claude login');
834
633
  }
835
634
 
836
635
  // OpenAI
@@ -842,10 +641,10 @@ async function autoSetup(cwd) {
842
641
  '$100': 'ChatGPT Pro ($100)',
843
642
  '$200': 'ChatGPT Pro ($200)',
844
643
  }[profile.providers.openai.plan] || profile.providers.openai.plan;
845
- result.actions.push(`${planLabel} via ${auth.openai.source}`);
644
+ result.actions.push(`${planLabel} (${auth.openai.source})`);
846
645
  } else {
847
646
  profile.providers.openai.enabled = false;
848
- result.warnings.push('OpenAI not authenticated');
647
+ result.warnings.push('Codex CLI not logged in — run: codex login');
849
648
  }
850
649
 
851
650
  // Mode
@@ -873,7 +672,6 @@ export {
873
672
  getAvailableProviders, isSoloBrain, getHeadModel,
874
673
  detectPlans, syncPreferencesToMemory,
875
674
  detectAuth, detectEnvironment,
876
- setupAuth, saveAuthKey, loadAuthKeys,
877
- getActiveKey,
675
+ saveSubscription, listSubscriptions,
878
676
  defaultProfile, autoSetup,
879
677
  };
package/src/session.mjs CHANGED
@@ -355,6 +355,90 @@ export function importReplitSessions(cwd = process.cwd()) {
355
355
  return sessions;
356
356
  }
357
357
 
358
+ // ─── Session metadata overlay ─────────────────────────────────────────────────
359
+
360
+ const SESSION_META_FILE = '.dualbrain/sessions.json';
361
+
362
+ function sessionMetaPath(cwd) {
363
+ return join(cwd ?? process.cwd(), SESSION_META_FILE);
364
+ }
365
+
366
+ export function getSessionMeta(cwd = process.cwd()) {
367
+ const p = sessionMetaPath(cwd);
368
+ if (!existsSync(p)) return {};
369
+ try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return {}; }
370
+ }
371
+
372
+ function saveSessionMeta(meta, cwd = process.cwd()) {
373
+ ensureDir(cwd);
374
+ const p = sessionMetaPath(cwd);
375
+ const tmp = p + '.tmp.' + process.pid;
376
+ writeFileSync(tmp, JSON.stringify(meta, null, 2) + '\n');
377
+ renameSync(tmp, p);
378
+ }
379
+
380
+ export function renameSession(sessionId, name, cwd = process.cwd()) {
381
+ const meta = getSessionMeta(cwd);
382
+ meta[sessionId] = { ...meta[sessionId], name, createdAt: meta[sessionId]?.createdAt ?? new Date().toISOString() };
383
+ saveSessionMeta(meta, cwd);
384
+ }
385
+
386
+ export function pinSession(sessionId, cwd = process.cwd()) {
387
+ const meta = getSessionMeta(cwd);
388
+ meta[sessionId] = { ...meta[sessionId], pinned: true, createdAt: meta[sessionId]?.createdAt ?? new Date().toISOString() };
389
+ saveSessionMeta(meta, cwd);
390
+ }
391
+
392
+ export function unpinSession(sessionId, cwd = process.cwd()) {
393
+ const meta = getSessionMeta(cwd);
394
+ meta[sessionId] = { ...meta[sessionId], pinned: false };
395
+ saveSessionMeta(meta, cwd);
396
+ }
397
+
398
+ export function categorizeSession(sessionId, category, cwd = process.cwd()) {
399
+ const meta = getSessionMeta(cwd);
400
+ meta[sessionId] = { ...meta[sessionId], category, createdAt: meta[sessionId]?.createdAt ?? new Date().toISOString() };
401
+ saveSessionMeta(meta, cwd);
402
+ }
403
+
404
+ const AUTO_LABEL_RULES = [
405
+ { keywords: ['auth', 'login', 'credential', 'security', 'token'], label: 'security' },
406
+ { keywords: ['ui', 'css', 'style', 'component', 'react', 'frontend'], label: 'ui' },
407
+ { keywords: ['refactor', 'cleanup', 'rename', 'reorganize'], label: 'refactor' },
408
+ { keywords: ['bug', 'fix', 'error', 'crash', 'broken'], label: 'bugfix' },
409
+ { keywords: ['test', 'spec', 'coverage'], label: 'testing' },
410
+ { keywords: ['deploy', 'ci', 'build', 'release'], label: 'devops' },
411
+ { keywords: ['plan', 'design', 'architect', 'brainstorm'], label: 'planning' },
412
+ ];
413
+
414
+ export function autoLabel(session) {
415
+ const text = (session.name || '').toLowerCase();
416
+ for (const { keywords, label } of AUTO_LABEL_RULES) {
417
+ if (keywords.some(kw => new RegExp(`\\b${kw}\\b`).test(text))) return label;
418
+ }
419
+ return null;
420
+ }
421
+
422
+ export function enrichSessions(sessions, cwd = process.cwd()) {
423
+ const meta = getSessionMeta(cwd);
424
+ const enriched = sessions.map(sess => {
425
+ const overlay = meta[sess.id] ?? {};
426
+ const category = overlay.category ?? autoLabel({ ...sess, name: overlay.name ?? sess.name });
427
+ return {
428
+ ...sess,
429
+ name: overlay.name ?? sess.name,
430
+ pinned: overlay.pinned ?? false,
431
+ category: category ?? null,
432
+ };
433
+ });
434
+ enriched.sort((a, b) => {
435
+ if (a.pinned && !b.pinned) return -1;
436
+ if (!a.pinned && b.pinned) return 1;
437
+ return new Date(b.lastActive) - new Date(a.lastActive);
438
+ });
439
+ return enriched;
440
+ }
441
+
358
442
  // ─── CLI (direct invocation) ──────────────────────────────────────────────────
359
443
 
360
444
  const isMain = process.argv[1]?.endsWith('session.mjs');