subconscious-cli 6.3.1 → 7.0.0

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.
@@ -1,594 +1,25 @@
1
- import crypto from 'node:crypto';
2
1
  import fs from 'node:fs';
3
2
  import os from 'node:os';
4
3
  import path from 'node:path';
5
- import {
6
- changedKeys,
7
- changedMcpServers,
8
- mcpServersOf,
9
- } from './claude-config-drift.js';
10
- import { copyUnindexedPlugins } from './claude-config-plugins.js';
11
- import { configDir } from './profiles.js';
12
4
 
13
- // Prepares the isolated Claude Code config dir (CLAUDE_CONFIG_DIR) for a subc
14
- // launch. Isolation exists to stop one leak: a /model pick inside subc landing
15
- // in ~/.claude/settings.json and changing plain `claude`. Everything else the
16
- // user set up in ~/.claude must keep working inside subc, so this dir links
17
- // back to it and rebuilds the files Claude Code keeps per config dir.
18
- //
19
- // The one rule: preparing never writes, moves or deletes anything the user's
20
- // own Claude Code reads. It writes only inside the isolated dir, and refuses a
21
- // dir that resolves to ~/.claude, into it, or to a folder that holds it.
5
+ // Where Claude Code keeps its config, for `subc sessions`. subc itself never
6
+ // moves Claude's config: a launch runs on the folder plain `claude` uses
7
+ // (~/.claude, or the user's own CLAUDE_CONFIG_DIR), and all Subconscious
8
+ // routing, credentials and model pinning ride the launch env and --settings,
9
+ // which persist nothing. Claude Code writes plan files, sessions, memories
10
+ // and skills where it always does.
22
11
 
23
- // Folders of ~/.claude that subc sessions share through a link: the user's
24
- // rules, skills and hooks, session history so resume and `subc` session lists
25
- // see one set of sessions, and the editor lock files so /ide finds the editor.
26
- // A missing one is created in ~/.claude first, so something made inside subc
27
- // (a first agent, a first skill) still lands in ~/.claude.
28
- const SHARED_DIRS = [
29
- 'rules',
30
- 'agents',
31
- 'commands',
32
- 'skills',
33
- 'output-styles',
34
- 'hooks',
35
- 'projects',
36
- 'file-history',
37
- 'todos',
38
- 'plans',
39
- 'ide',
40
- ];
41
-
42
- // Files of ~/.claude shared through a link when the user has them.
43
- const SHARED_FILES = ['CLAUDE.md', 'history.jsonl', 'keybindings.json'];
44
-
45
- /** Every entry of ~/.claude that subc sessions share through a link. */
46
- export const SHARED_ENTRIES = Object.freeze([...SHARED_FILES, ...SHARED_DIRS]);
47
-
48
- // What the last launch wrote into the subc copies, to find what a session
49
- // changed there before the next rebuild replaces it.
50
- const WRITTEN_FILE = '.subc-written.json';
51
-
52
- // Plugin index files copied into the subc plugins dir on each launch. The
53
- // install paths inside still point at ~/.claude/plugins/cache, which Claude
54
- // Code treats as outside its own cache root: a subc session can load those
55
- // plugins but never marks them orphaned or deletes them. The cache itself is
56
- // never linked, so an uninstall or cache sweep in subc cannot reach it.
57
- const PLUGIN_INDEX_FILES = [
58
- 'installed_plugins.json',
59
- 'known_marketplaces.json',
60
- 'blocklist.json',
61
- 'config.json',
62
- ];
63
-
64
- // User settings keys subc owns inside its sessions. `model` is the leak this
65
- // isolation exists for; the subc value survives each rebuild.
66
- const SUBC_OWNED_SETTINGS = ['model'];
67
- // User settings keys dropped because they would replace the gateway credential.
68
- const DROPPED_SETTINGS = ['apiKeyHelper'];
69
-
70
- // ~/.claude.json keys copied in. Login state (oauthAccount, primaryApiKey)
71
- // never is: subc sessions authenticate with the gateway token.
72
- const COPIED_STATE = ['mcpServers', 'hasCompletedOnboarding', 'theme'];
73
- const COPIED_PROJECT_STATE = [
74
- 'mcpServers',
75
- 'enabledMcpjsonServers',
76
- 'disabledMcpjsonServers',
77
- 'hasTrustDialogAccepted',
78
- 'allowedTools',
79
- ];
80
-
81
- const isObject = (value) =>
82
- value !== null && typeof value === 'object' && !Array.isArray(value);
83
-
84
- function pick(source, keys) {
85
- return Object.fromEntries(
86
- keys
87
- .filter((key) => Object.hasOwn(source, key))
88
- .map((key) => [key, source[key]]),
89
- );
90
- }
91
-
92
- /** Text of a regular file (links followed), `undefined` when missing. */
93
- function readRegularFile(file) {
94
- let stat;
95
- try {
96
- stat = fs.statSync(file);
97
- } catch (error) {
98
- if (error.code === 'ENOENT') return undefined;
99
- throw error;
100
- }
101
- // A FIFO or device would block or stream forever.
102
- if (!stat.isFile()) throw new Error('not a regular file');
103
- return fs.readFileSync(file, 'utf8');
104
- }
105
-
106
- /** Parsed JSON object, `undefined` when missing; throws when invalid. */
107
- function readJsonObject(file) {
108
- const text = readRegularFile(file);
109
- if (text === undefined) return undefined;
110
- const value = JSON.parse(text);
111
- if (!isObject(value)) throw new Error('not a JSON object');
112
- return value;
113
- }
114
-
115
- // The temp name is unguessable and created exclusively, so a planted link at
116
- // that name cannot redirect the write. The rename replaces a link at `file`
117
- // with a regular file instead of writing through it.
118
- function writeAtomic(file, text, mode = 0o644) {
119
- const temp = `${file}.${crypto.randomBytes(6).toString('hex')}.tmp`;
120
- fs.writeFileSync(temp, text, { mode, flag: 'wx' });
121
- try {
122
- fs.renameSync(temp, file);
123
- } catch (error) {
124
- fs.rmSync(temp, { force: true });
125
- throw error;
126
- }
127
- }
128
-
129
- const writeJsonAtomic = (file, value, mode) =>
130
- writeAtomic(file, `${JSON.stringify(value, null, 2)}\n`, mode);
131
-
132
- function lstatOrUndefined(file) {
133
- try {
134
- return fs.lstatSync(file);
135
- } catch {
136
- return undefined;
137
- }
138
- }
139
-
140
- /** The real path, resolving the nearest existing ancestor of a missing one. */
141
- function realPath(file) {
142
- const missing = [];
143
- let current = path.resolve(file);
144
- for (;;) {
145
- try {
146
- // .native returns the on-disk case, so ~/.CLAUDE resolves to ~/.claude.
147
- return path.join(fs.realpathSync.native(current), ...missing);
148
- } catch {
149
- const parent = path.dirname(current);
150
- if (parent === current) return path.resolve(file);
151
- missing.unshift(path.basename(current));
152
- current = parent;
153
- }
154
- }
155
- }
156
-
157
- /** Device and inode of `file` (links followed), `undefined` when missing. */
158
- function identity(file) {
159
- try {
160
- const stat = fs.statSync(file);
161
- return `${stat.dev}:${stat.ino}`;
162
- } catch {
163
- return undefined;
164
- }
165
- }
166
-
167
- /** Identities of `file` and every folder above it. */
168
- function selfAndAncestors(file) {
169
- const ids = new Set();
170
- let current = realPath(file);
171
- for (;;) {
172
- const id = identity(current);
173
- if (id) ids.add(id);
174
- const parent = path.dirname(current);
175
- if (parent === current) return ids;
176
- current = parent;
177
- }
178
- }
179
-
180
- // Files of the user's own config that prepare must never replace, paired
181
- // with the path in the subc dir that prepare writes.
182
- // A plugins link from an earlier build is unlinked, never written through,
183
- // before the plugin files are written, so its target needs no check.
184
- function guardedWrites(dir, source, stateFile) {
185
- const pluginsLink = lstatOrUndefined(path.join(dir, 'plugins'));
186
- const plugins = pluginsLink?.isSymbolicLink() ? [] : PLUGIN_INDEX_FILES;
187
- return [
188
- [path.join(source, 'settings.json'), path.join(dir, 'settings.json')],
189
- [stateFile, path.join(dir, '.claude.json')],
190
- ...plugins.map((name) => [
191
- path.join(source, 'plugins', name),
192
- path.join(dir, 'plugins', name),
193
- ]),
194
- ];
195
- }
196
-
197
- /**
198
- * Why `dir` must not be prepared, or `undefined` when it is safe. Compares
199
- * file identities, not path text, so no spelling, case or link can alias the
200
- * user's config into the dir.
201
- */
202
- function unsafeDirReason(dir, source, stateFile) {
203
- if (!path.isAbsolute(dir)) return `${dir} is not an absolute path`;
204
- const sourceId = identity(source);
205
- if (sourceId && selfAndAncestors(dir).has(sourceId)) {
206
- return `${dir} resolves into your Claude config ${source}`;
207
- }
208
- const dirIds = selfAndAncestors(dir);
209
- for (const name of [...SHARED_ENTRIES, 'plugins']) {
210
- const targetId = identity(path.join(source, name));
211
- if (targetId && dirIds.has(targetId)) {
212
- return `${dir} resolves into the folder behind ${path.join(source, name)}`;
213
- }
214
- }
215
- const dirId = identity(dir);
216
- const homeJson = identity(path.dirname(realPath(stateFile)));
217
- if (dirId && (selfAndAncestors(source).has(dirId) || dirId === homeJson)) {
218
- return `${dir} resolves to a folder that holds your Claude config`;
219
- }
220
- for (const [user, own] of guardedWrites(dir, source, stateFile)) {
221
- const userId = identity(user);
222
- // A link at `own` is replaced by the rename, never written through.
223
- if (lstatOrUndefined(own)?.isSymbolicLink()) continue;
224
- if (userId && userId === identity(own)) {
225
- return `${own} is your own ${path.basename(user)}`;
226
- }
227
- }
228
- return undefined;
229
- }
230
-
231
- function sameFile(a, b) {
232
- try {
233
- const left = fs.statSync(a);
234
- const right = fs.statSync(b);
235
- return left.dev === right.dev && left.ino === right.ino;
236
- } catch {
237
- return false;
238
- }
239
- }
240
-
241
- function linkEntry(target, link) {
242
- const isDir = fs.statSync(target).isDirectory();
243
- try {
244
- // Junctions need no privilege on Windows; elsewhere the type is ignored.
245
- fs.symlinkSync(target, link, isDir ? 'junction' : 'file');
246
- } catch (error) {
247
- // Windows file symlinks need Developer Mode; a hard link shares the file.
248
- if (isDir || process.platform !== 'win32') throw error;
249
- fs.linkSync(target, link);
250
- }
251
- }
252
-
253
- function shareEntry(dir, source, name, warnings) {
254
- const target = path.join(source, name);
255
- const link = path.join(dir, name);
256
- let existing = lstatOrUndefined(link);
257
- // A link from an earlier launch can point at another source. Removing a
258
- // link never touches its target.
259
- if (
260
- existing?.isSymbolicLink() &&
261
- path.resolve(dir, fs.readlinkSync(link)) !== path.resolve(target)
262
- ) {
263
- fs.unlinkSync(link);
264
- existing = undefined;
265
- }
266
- if (SHARED_DIRS.includes(name) && !fs.existsSync(target)) {
267
- fs.mkdirSync(target, { recursive: true });
268
- }
269
- if (!fs.existsSync(target)) return;
270
- if (existing?.isSymbolicLink() || (existing && sameFile(link, target))) {
271
- return;
272
- }
273
- if (existing) {
274
- warnings.push(
275
- `${link} is a real ${existing.isDirectory() ? 'directory' : 'file'}, so subc sessions do not share ${target}; move it aside to share it.`,
276
- );
277
- return;
278
- }
279
- linkEntry(target, link);
280
- }
281
-
282
- function shareEntries(dir, source, warnings) {
283
- for (const name of SHARED_ENTRIES) {
284
- try {
285
- shareEntry(dir, source, name, warnings);
286
- } catch (error) {
287
- warnings.push(
288
- `Could not share ${path.join(source, name)}: ${error.message}`,
289
- );
290
- }
291
- }
292
- }
293
-
294
- function copyPluginIndex(dir, source, warnings) {
295
- const own = path.join(dir, 'plugins');
296
- const existing = lstatOrUndefined(own);
297
- // An earlier build linked the whole folder. Removing a link never touches
298
- // its target.
299
- if (existing?.isSymbolicLink()) fs.unlinkSync(own);
300
- else if (existing && !existing.isDirectory()) {
301
- warnings.push(`${own} is not a directory, so plugins are not shared.`);
302
- return;
303
- }
304
- fs.mkdirSync(own, { recursive: true });
305
- for (const name of PLUGIN_INDEX_FILES) {
306
- try {
307
- const text = readRegularFile(path.join(source, 'plugins', name));
308
- if (text !== undefined) writeAtomic(path.join(own, name), text, 0o600);
309
- } catch (error) {
310
- warnings.push(
311
- `Could not copy ${path.join(source, 'plugins', name)}: ${error.message}`,
312
- );
313
- }
314
- }
315
- }
316
-
317
- function rebuildSettings(dir, source, launchEnvNames, written, warnings) {
318
- const file = path.join(dir, 'settings.json');
319
- let user = {};
320
- try {
321
- user = readJsonObject(path.join(source, 'settings.json')) ?? {};
322
- } catch (error) {
323
- warnings.push(
324
- `Ignored ${path.join(source, 'settings.json')} in subc sessions: ${error.message}`,
325
- );
326
- }
327
- let current = {};
328
- try {
329
- current = readJsonObject(file) ?? {};
330
- } catch {
331
- current = {};
332
- }
333
- const settings = { ...user };
334
- for (const key of [...SUBC_OWNED_SETTINGS, ...DROPPED_SETTINGS]) {
335
- delete settings[key];
336
- }
337
- if (isObject(user.env)) {
338
- const env = Object.fromEntries(
339
- Object.entries(user.env).filter(([name]) => !launchEnvNames.has(name)),
340
- );
341
- if (Object.keys(env).length) settings.env = env;
342
- else delete settings.env;
343
- }
344
- const lost = written
345
- ? changedKeys(written, current, SUBC_OWNED_SETTINGS)
346
- : [];
347
- if (lost.length) {
348
- const saved = path.join(dir, 'settings.discarded.json');
349
- writeJsonAtomic(saved, current, 0o600);
350
- warnings.push(
351
- `Settings changed inside your last subc session are not kept: ${lost.join(', ')}. They are saved in ${saved}. To keep them, add them in plain claude.`,
352
- );
353
- }
354
- const next = { ...settings, ...pick(current, SUBC_OWNED_SETTINGS) };
355
- writeJsonAtomic(file, next);
356
- return next;
357
- }
358
-
359
- function mergeProjects(current, user) {
360
- const projects = isObject(current) ? { ...current } : {};
361
- for (const [project, state] of Object.entries(user)) {
362
- if (!isObject(state)) continue;
363
- const copied = pick(state, COPIED_PROJECT_STATE);
364
- if (!Object.keys(copied).length) continue;
365
- const existing = isObject(projects[project]) ? projects[project] : {};
366
- projects[project] = { ...existing, ...copied };
367
- }
368
- return projects;
369
- }
370
-
371
- function syncState(dir, stateFile, written, warnings) {
372
- const file = path.join(dir, '.claude.json');
373
- let user;
374
- let current;
375
- try {
376
- user = readJsonObject(stateFile);
377
- } catch (error) {
378
- warnings.push(`Ignored ${stateFile} in subc sessions: ${error.message}`);
379
- return undefined;
380
- }
381
- if (!user) return undefined;
382
- try {
383
- current = readJsonObject(file) ?? {};
384
- } catch (error) {
385
- warnings.push(`Left ${file} unchanged: ${error.message}`);
386
- return undefined;
387
- }
388
- const lost = written ? changedMcpServers(written, mcpServersOf(current)) : [];
389
- if (lost.length) {
390
- const saved = path.join(dir, 'mcp.discarded.json');
391
- writeJsonAtomic(saved, mcpServersOf(current), 0o600);
392
- warnings.push(
393
- `MCP servers added inside your last subc session are not kept: ${lost.join(', ')}. They are saved in ${saved}. To keep them, add them in plain claude.`,
394
- );
395
- }
396
- const next = { ...current, ...pick(user, COPIED_STATE) };
397
- if (isObject(user.projects)) {
398
- next.projects = mergeProjects(current.projects, user.projects);
399
- }
400
- writeJsonAtomic(file, next, 0o600);
401
- return mcpServersOf(next);
402
- }
403
-
404
- /** A JSON object, or `undefined` when missing or invalid; errors are reported elsewhere. */
405
- function readQuietly(file) {
406
- try {
407
- return readJsonObject(file);
408
- } catch {
409
- return undefined;
410
- }
411
- }
412
-
413
- /** A free name beside `dir` for a leftover folder: `<dir>.old-<time>`. */
414
- function asideName(dir) {
415
- const stamp = new Date().toISOString().replace(/[:.]/g, '-');
416
- for (let n = 0; ; n++) {
417
- const name = `${dir}.old-${stamp}${n ? `-${n}` : ''}`;
418
- if (!lstatOrUndefined(name)) return name;
419
- }
420
- }
421
-
422
- /**
423
- * Make sure `dir` is a subc dir: an earlier launch's marker, or a new or
424
- * empty folder that is claimed now. A Claude config there without the
425
- * marker is left by an earlier subc build: it is renamed aside, never
426
- * deleted, and a fresh dir is claimed. When the user's own
427
- * CLAUDE_CONFIG_DIR is `dir` (`userDir`), it is theirs and is refused.
428
- * Returns why a folder is not a subc dir, or `undefined`.
429
- */
430
- function claimDir(dir, userDir, warnings) {
431
- const marker = path.join(dir, WRITTEN_FILE);
432
- if (lstatOrUndefined(marker)) return undefined;
433
- if (lstatOrUndefined(dir) && looksLikeClaudeConfig(dir)) {
434
- if (userDir) {
435
- return `${dir} holds a Claude config that subc did not make`;
436
- }
437
- const aside = asideName(dir);
438
- fs.renameSync(dir, aside);
439
- warnings.push(
440
- `Moved an old subc Claude config folder to ${aside}; subc sessions now start from your own Claude setup.`,
441
- );
442
- }
443
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
444
- writeJsonAtomic(marker, {}, 0o600);
445
- return undefined;
446
- }
447
-
448
- function readWritten(dir) {
449
- try {
450
- return readJsonObject(path.join(dir, WRITTEN_FILE)) ?? {};
451
- } catch {
452
- return {};
453
- }
454
- }
455
-
456
- /**
457
- * Make `dir` a Claude Code config dir that shares the user's ~/.claude setup
458
- * and keeps only subc's own settings apart. Never writes into ~/.claude except
459
- * to create ~/.claude/projects, and never deletes anything there. Problems
460
- * come back as warnings; preparing never throws, so a launch always proceeds.
461
- *
462
- * `source` is the user's own Claude config folder (~/.claude, or their
463
- * CLAUDE_CONFIG_DIR) and `stateFile` its .claude.json (~/.claude.json, or
464
- * .claude.json inside their CLAUDE_CONFIG_DIR).
465
- *
466
- * @param {{ dir: string, home?: string, source?: string, stateFile?: string,
467
- * launchEnvNames?: Iterable<string>, userDir?: boolean }} options
468
- * `userDir` means the user's own CLAUDE_CONFIG_DIR is `dir`.
469
- * @returns {{ warnings: string[], refused: boolean }} `refused` means the
470
- * dir aliases the user's own config; the launch must not run on it.
471
- */
472
- export function prepareClaudeConfigDir({
473
- dir,
474
- home = os.homedir(),
475
- source = path.join(home, '.claude'),
476
- stateFile = path.join(home, '.claude.json'),
477
- launchEnvNames = [],
478
- userDir = false,
479
- }) {
480
- const warnings = [];
481
- let unsafe = unsafeDirReason(dir, source, stateFile);
482
- if (!unsafe) {
483
- try {
484
- unsafe = claimDir(dir, userDir, warnings);
485
- } catch (error) {
486
- unsafe = `could not prepare it: ${error.message}`;
487
- }
488
- }
489
- if (unsafe) {
490
- warnings.push(
491
- `Refused to use ${dir} as the subc Claude config dir: ${unsafe}. Claude Code would write into your own config there. Point SUBC_CONFIG_DIR somewhere else, or run subc claude --no-isolate.`,
492
- );
493
- return { warnings, refused: true };
494
- }
495
- try {
496
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
497
- shareEntries(dir, source, warnings);
498
- copyPluginIndex(dir, source, warnings);
499
- copyUnindexedPlugins({
500
- dir,
501
- source,
502
- enabledPlugins: readQuietly(path.join(source, 'settings.json'))
503
- ?.enabledPlugins,
504
- indexed: readQuietly(
505
- path.join(source, 'plugins', 'installed_plugins.json'),
506
- )?.plugins,
507
- warnings,
508
- });
509
- const written = readWritten(dir);
510
- const settings = rebuildSettings(
511
- dir,
512
- source,
513
- new Set(launchEnvNames),
514
- written.settings,
515
- warnings,
516
- );
517
- const mcp = syncState(dir, stateFile, written.mcp, warnings);
518
- writeJsonAtomic(path.join(dir, WRITTEN_FILE), { settings, mcp }, 0o600);
519
- } catch (error) {
520
- warnings.push(
521
- `Could not prepare the subc Claude config dir ${dir}: ${error.message}`,
522
- );
523
- }
524
- return { warnings, refused: false };
525
- }
526
-
527
- // Carries the user's own config folder into a subc session, so a `subc
528
- // claude` started inside it shares from that folder, not from the subc dir
529
- // its shell now has as CLAUDE_CONFIG_DIR.
530
- const SOURCE_ENV = 'SUBC_CLAUDE_CONFIG_SOURCE';
531
-
532
- // CLAUDE_CODE_ISOLATE values that opt out; the launch runs on the user's
533
- // own folder and nothing is prepared.
534
- const ISOLATE_OFF = new Set(['off', '0', 'false', 'no']);
535
-
536
- /**
537
- * Whether `folder` is a Claude config folder Claude Code has used: a typo or
538
- * a code repo would otherwise get shared folders created in it.
539
- */
12
+ /** Whether `folder` is one Claude Code has used: a typo or a code repo is not. */
540
13
  function looksLikeClaudeConfig(folder) {
541
14
  return ['settings.json', '.claude.json', 'projects'].some((name) =>
542
15
  fs.existsSync(path.join(folder, name)),
543
16
  );
544
17
  }
545
18
 
546
- /** Whether the user's own env names `dir` as their Claude config folder. */
547
- function pointsAt(inputEnv, dir) {
548
- return [SOURCE_ENV, 'CLAUDE_CONFIG_DIR'].some((name) => {
549
- const value = inputEnv[name]?.trim();
550
- if (!value || !path.isAbsolute(value)) return false;
551
- if (path.resolve(value) === path.resolve(dir)) return true;
552
- const id = identity(value);
553
- return Boolean(id) && id === identity(dir);
554
- });
555
- }
556
-
557
- /**
558
- * The user's own Claude config folder for a launch: the one carried from an
559
- * outer subc session, else their CLAUDE_CONFIG_DIR, else ~/.claude. The subc
560
- * dir itself is never a source.
561
- */
562
- function launchSource(inputEnv, dir, home, warnings) {
563
- const fallback = {
564
- source: path.join(home, '.claude'),
565
- stateFile: path.join(home, '.claude.json'),
566
- };
567
- for (const name of [SOURCE_ENV, 'CLAUDE_CONFIG_DIR']) {
568
- const value = inputEnv[name]?.trim();
569
- if (!value) continue;
570
- if (!path.isAbsolute(value)) {
571
- warnings.push(
572
- `${name}=${value} is not an absolute path, so subc sessions share ~/.claude instead.`,
573
- );
574
- return fallback;
575
- }
576
- if (identity(value) && identity(value) === identity(dir)) continue;
577
- if (path.resolve(value) === path.resolve(dir)) continue;
578
- if (!looksLikeClaudeConfig(value)) {
579
- warnings.push(
580
- `${name}=${value} is not a Claude config folder, so subc sessions share ~/.claude instead.`,
581
- );
582
- return fallback;
583
- }
584
- return { source: value, stateFile: path.join(value, '.claude.json') };
585
- }
586
- return fallback;
587
- }
588
-
589
19
  /**
590
- * The Claude config folder plain `claude` uses: the source a subc launch
591
- * shares from. `subc sessions` reads Claude sessions there.
20
+ * The Claude config folder plain `claude` uses: the user's CLAUDE_CONFIG_DIR
21
+ * when it names a Claude config folder, else ~/.claude. subc sessions run
22
+ * there too, so `subc sessions` reads the same set of sessions.
592
23
  *
593
24
  * @param {{ env?: Record<string, string | undefined>, home?: string }} [options]
594
25
  * @returns {string}
@@ -597,54 +28,7 @@ export function claudeSourceDir({
597
28
  env = process.env,
598
29
  home = os.homedir(),
599
30
  } = {}) {
600
- const subcBase =
601
- env.SUBC_CONFIG_DIR?.trim() || path.join(home, '.subconscious');
602
- return launchSource(env, path.join(subcBase, 'claude-code'), home, []).source;
603
- }
604
-
605
- /**
606
- * Prepare the subc config dir for a planned Claude Code launch. It shares
607
- * the folder the user's plain `claude` uses. An opted-out launch (no
608
- * CLAUDE_CONFIG_DIR) is left alone.
609
- *
610
- * @param {{ env: { name: string }[] }} agent
611
- * @param {Record<string, string | undefined>} launchEnv the planned launch env
612
- * @param {Record<string, string | undefined>} inputEnv the env before planning
613
- * @param {{ home?: string, subcDir?: string }} [options] `subcDir` is the
614
- * subc config dir; it defaults to $SUBC_CONFIG_DIR/claude-code
615
- * @returns {{ warnings: string[], refused: boolean,
616
- * env: Record<string, string> }} `env` goes into the launch env
617
- */
618
- export function prepareClaudeLaunch(agent, launchEnv, inputEnv, options = {}) {
619
- const none = { warnings: [], refused: false, env: {} };
620
- const dir = launchEnv.CLAUDE_CONFIG_DIR?.trim();
621
- if (!dir) return none;
622
- const isolate = String(launchEnv.CLAUDE_CODE_ISOLATE ?? '')
623
- .trim()
624
- .toLowerCase();
625
- // Opted out: CLAUDE_CONFIG_DIR is the user's own folder.
626
- if (ISOLATE_OFF.has(isolate)) return none;
627
- const home = options.home ?? os.homedir();
628
- // Only the subc dir itself is ever prepared, whatever the launch env says.
629
- const subcBase =
630
- launchEnv.SUBC_CONFIG_DIR?.trim() ||
631
- (options.home ? path.join(home, '.subconscious') : configDir());
632
- const subcDir = options.subcDir ?? path.join(subcBase, 'claude-code');
633
- if (path.resolve(dir) !== path.resolve(subcDir)) return none;
634
- const warnings = [];
635
- const { source, stateFile } = launchSource(inputEnv, dir, home, warnings);
636
- const launchEnvNames = agent.env.map((entry) => entry.name);
637
- const prepared = prepareClaudeConfigDir({
638
- dir,
639
- home,
640
- source,
641
- stateFile,
642
- launchEnvNames,
643
- userDir: pointsAt(inputEnv, dir),
644
- });
645
- return {
646
- warnings: [...warnings, ...prepared.warnings],
647
- refused: prepared.refused,
648
- env: { [SOURCE_ENV]: source },
649
- };
31
+ const own = env.CLAUDE_CONFIG_DIR?.trim();
32
+ if (own && path.isAbsolute(own) && looksLikeClaudeConfig(own)) return own;
33
+ return path.join(home, '.claude');
650
34
  }