codebay 0.13.0 → 0.15.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.
Files changed (85) hide show
  1. package/.mochi/manifest.json +522 -443
  2. package/.mochi/svelte-client/HydratableIsland-smgsf0ma.js +1 -0
  3. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-9vc087ep.js +2 -0
  4. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-a48xq47a.js +14 -0
  5. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-d4zehrqz.js +3 -0
  6. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-t5175pf5.js +44 -0
  7. package/.mochi/svelte-client/chunk-0mstgbtr.js +2 -0
  8. package/.mochi/svelte-client/chunk-0q9g0kpg.js +1 -0
  9. package/.mochi/svelte-client/chunk-3fwjzw1r.js +1 -0
  10. package/.mochi/svelte-client/{chunk-spk1b2r4.js → chunk-5pg0be5f.js} +1 -1
  11. package/.mochi/svelte-client/{chunk-ccfh8hxz.js → chunk-a2hty99b.js} +1 -1
  12. package/.mochi/svelte-client/{chunk-0mc09cyb.js → chunk-ds9yn9cq.js} +1 -1
  13. package/.mochi/svelte-client/{chunk-phf7v5jj.js → chunk-fn03gxer.js} +1 -1
  14. package/.mochi/svelte-client/chunk-jhgq7f65.js +2 -0
  15. package/.mochi/svelte-client/chunk-p5a07kx1.js +1 -0
  16. package/.mochi/svelte-client/chunk-p5wpyhsf.js +2 -0
  17. package/.mochi/svelte-client/chunk-t2kfvtb6.js +2 -0
  18. package/.mochi/svelte-client/{chunk-x0m3k0rn.js → chunk-v3wg9mdy.js} +1 -1
  19. package/.mochi/svelte-compile/{App-94xsdn0w.server.js → App-68f9a9fq.server.js} +64 -59
  20. package/.mochi/svelte-compile/{Avatars-fnccw47h.server.js → Avatars-vyh3rwxx.server.js} +14 -29
  21. package/.mochi/svelte-compile/Instance-yfw7s5mp.server.js +627 -0
  22. package/.mochi/svelte-compile/{Settings-h7gsjyz3.server.js → Settings-0b5t9kw9.server.js} +348 -151
  23. package/.mochi/svelte-compile/{chunk-w0hca7va.js → chunk-0y3wrae9.js} +13 -1
  24. package/.mochi/svelte-compile/{chunk-ed46m58e.js → chunk-2qzc07gt.js} +13 -1
  25. package/.mochi/svelte-compile/chunk-ef3hvegw.js +21 -0
  26. package/.mochi/svelte-compile/chunk-f9td8ej2.js +24 -0
  27. package/.mochi/svelte-compile/{chunk-pqb403fh.js → chunk-g0eravvj.js} +5 -16
  28. package/.mochi/svelte-css/AgentLogBox-38adby5iygny7.css +1 -0
  29. package/.mochi/svelte-css/{InstanceCard-25z7nas7y98zk.css → InstanceCard-3vxozrp9uoads.css} +1 -1
  30. package/.mochi/svelte-css/{SettingsView-bccw34agd2bj.css → SettingsView-2400myxb9ot56.css} +1 -1
  31. package/README.md +32 -1
  32. package/migrations/0009_agent_runs.sql +24 -0
  33. package/migrations/0010_agent_run_model.sql +2 -0
  34. package/package.json +6 -2
  35. package/public/favicon.svg +9 -0
  36. package/src/components/AgentLogBox.svelte +458 -0
  37. package/src/components/AppShell.svelte +11 -0
  38. package/src/components/FolderBrowser.svelte +13 -5
  39. package/src/components/InstanceCard.svelte +38 -0
  40. package/src/components/InstanceView.svelte +8 -0
  41. package/src/components/SettingsView.svelte +260 -0
  42. package/src/components/UiShowcase.svelte +4 -2
  43. package/src/container-injections/claude-code-credentials.ts +2 -3
  44. package/src/container-injections/claude-code-skills.ts +125 -0
  45. package/src/container-injections/claude-output-style.ts +2 -3
  46. package/src/container-injections/claude-statusline.ts +9 -17
  47. package/src/container-injections/github-credentials.ts +12 -1
  48. package/src/index.ts +32 -7
  49. package/src/lib/agent-run-stream.ts +246 -0
  50. package/src/lib/agent-runs.server.ts +724 -0
  51. package/src/lib/auth.server.ts +11 -2
  52. package/src/lib/build-freshness.server.ts +71 -0
  53. package/src/lib/config.server.ts +29 -6
  54. package/src/lib/container-files.server.ts +23 -1
  55. package/src/lib/data-dir-lock.server.ts +78 -0
  56. package/src/lib/db.server.ts +120 -1
  57. package/src/lib/devcontainer.server.ts +60 -14
  58. package/src/lib/docker-client.server.ts +11 -5
  59. package/src/lib/drives.ts +11 -0
  60. package/src/lib/exec.server.ts +66 -5
  61. package/src/lib/host-claude.server.ts +31 -0
  62. package/src/lib/injections.server.ts +2 -0
  63. package/src/lib/instances.server.ts +45 -3
  64. package/src/lib/log-capture.server.ts +17 -5
  65. package/src/lib/mcp-auth.server.ts +52 -0
  66. package/src/lib/picker.server.ts +23 -2
  67. package/src/lib/sandbox-ops.server.ts +263 -0
  68. package/src/mcp/routes.server.ts +25 -0
  69. package/src/mcp/server.server.ts +52 -0
  70. package/src/mcp/tools.server.ts +494 -0
  71. package/src/pages/Settings.svelte +15 -0
  72. package/src/routes.ts +72 -1
  73. package/src/shell.html +1 -0
  74. package/src/types.ts +15 -0
  75. package/.mochi/svelte-client/HydratableIsland-zrdsrsmg.js +0 -1
  76. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-v7y7dh53.js +0 -2
  77. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-tmwd6fpb.js +0 -14
  78. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-m7t64dbh.js +0 -1
  79. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-wedc9x9y.js +0 -39
  80. package/.mochi/svelte-client/chunk-5jwfs4p9.js +0 -2
  81. package/.mochi/svelte-client/chunk-6qg4q98a.js +0 -2
  82. package/.mochi/svelte-client/chunk-tmeh70wg.js +0 -2
  83. package/.mochi/svelte-client/chunk-vs1yjbqt.js +0 -2
  84. package/.mochi/svelte-client/chunk-y7f4jbdm.js +0 -2
  85. package/.mochi/svelte-compile/Instance-knnkcn2p.server.js +0 -377
@@ -10,6 +10,7 @@
10
10
  import ThemePicker from './ThemePicker.svelte';
11
11
  import Layers from '@lucide/svelte/icons/layers';
12
12
  import FolderMinus from '@lucide/svelte/icons/folder-minus';
13
+ import FolderCog from '@lucide/svelte/icons/folder-cog';
13
14
  import Trash2 from '@lucide/svelte/icons/trash-2';
14
15
  import Hammer from '@lucide/svelte/icons/hammer';
15
16
  import KeyRound from '@lucide/svelte/icons/key-round';
@@ -23,6 +24,7 @@
23
24
  import ListOrdered from '@lucide/svelte/icons/list-ordered';
24
25
  import Boxes from '@lucide/svelte/icons/boxes';
25
26
  import Puzzle from '@lucide/svelte/icons/puzzle';
27
+ import Plug from '@lucide/svelte/icons/plug';
26
28
  import { Toaster } from 'svelte-french-toast';
27
29
  import { TOAST_OPTIONS } from '../toast.ts';
28
30
  import { flushSync } from 'svelte';
@@ -69,6 +71,11 @@
69
71
  disableBuildCache,
70
72
  copyIgnorePatterns,
71
73
  builtinCopyIgnore,
74
+ claudeConfigDir,
75
+ mcpEnabled,
76
+ mcpToken,
77
+ mcpUrl,
78
+ mcpPrAttribution,
72
79
  gitIdentityEnabled,
73
80
  gitIdentityName,
74
81
  gitIdentityEmail,
@@ -110,6 +117,11 @@
110
117
  disableBuildCache: boolean;
111
118
  copyIgnorePatterns: string;
112
119
  builtinCopyIgnore: string;
120
+ claudeConfigDir: string;
121
+ mcpEnabled: boolean;
122
+ mcpToken: string;
123
+ mcpUrl: string;
124
+ mcpPrAttribution: boolean;
113
125
  gitIdentityEnabled: boolean;
114
126
  gitIdentityName: string;
115
127
  gitIdentityEmail: string;
@@ -372,6 +384,80 @@
372
384
  copyIgnoreFormEl?.requestSubmit();
373
385
  }
374
386
 
387
+ // Blank means "use ~/.claude" — the host dir the Claude config injections read from.
388
+ // svelte-ignore state_referenced_locally
389
+ let claudeDir = $state(claudeConfigDir);
390
+ let savingClaudeDir = $state(false);
391
+ let claudeDirError = $state<string | null>(null);
392
+ let claudeDirSaved = $state(false);
393
+ let claudeDirFormEl: HTMLFormElement | undefined;
394
+
395
+ const claudeDirOpts = saveOpts<{ dir: string }>({
396
+ setSaving: (v) => (savingClaudeDir = v),
397
+ setError: (v) => (claudeDirError = v),
398
+ setMsg: (v) => (claudeDirSaved = !!v),
399
+ onSuccess: (data) => {
400
+ claudeDir = data?.dir ?? claudeDir;
401
+ claudeDirSaved = true;
402
+ }
403
+ });
404
+
405
+ function resetClaudeDir() {
406
+ flushSync(() => (claudeDir = ''));
407
+ claudeDirFormEl?.requestSubmit();
408
+ }
409
+
410
+ // svelte-ignore state_referenced_locally
411
+ let mcp = $state(mcpEnabled);
412
+ // Unlike every other secret here this one round-trips to the client — a human has to copy it.
413
+ // svelte-ignore state_referenced_locally
414
+ let mcpTokenValue = $state(mcpToken);
415
+ let savingMcpToggle = $state(false);
416
+ let mcpToggleError = $state<string | null>(null);
417
+ let savingMcpToken = $state(false);
418
+ let mcpTokenError = $state<string | null>(null);
419
+ let mcpCopied = $state<string | null>(null);
420
+
421
+ const mcpToggleOpts = toggleOpts<{ enabled: boolean; token: string }>({
422
+ set: (v) => (mcp = v),
423
+ setSaving: (v) => (savingMcpToggle = v),
424
+ setError: (v) => (mcpToggleError = v),
425
+ onSuccess: (data) => (mcpTokenValue = data?.token ?? '')
426
+ });
427
+
428
+ const mcpTokenOpts = saveOpts<{ token: string }>({
429
+ setSaving: (v) => (savingMcpToken = v),
430
+ setError: (v) => (mcpTokenError = v),
431
+ onSuccess: (data) => (mcpTokenValue = data?.token ?? ''),
432
+ confirmMessage:
433
+ 'Regenerate the MCP token? Any agent already configured with the current token will stop working.'
434
+ });
435
+
436
+ // svelte-ignore state_referenced_locally
437
+ let mcpAttribution = $state(mcpPrAttribution);
438
+ let savingMcpAttribution = $state(false);
439
+ let mcpAttributionError = $state<string | null>(null);
440
+
441
+ const mcpAttributionOpts = toggleOpts({
442
+ set: (v) => (mcpAttribution = v),
443
+ setSaving: (v) => (savingMcpAttribution = v),
444
+ setError: (v) => (mcpAttributionError = v)
445
+ });
446
+
447
+ const mcpCommand = $derived(
448
+ `claude mcp add --transport http codebay ${mcpUrl} --header "Authorization: Bearer ${mcpTokenValue}"`
449
+ );
450
+
451
+ async function copyMcp(label: string, text: string) {
452
+ try {
453
+ await navigator.clipboard.writeText(text);
454
+ mcpCopied = label;
455
+ setTimeout(() => (mcpCopied = null), 1500);
456
+ } catch {
457
+ mcpTokenError = 'Could not copy — select the text and copy it manually.';
458
+ }
459
+ }
460
+
375
461
  // svelte-ignore state_referenced_locally
376
462
  let gitIdentity = $state(gitIdentityEnabled);
377
463
  let savingGitToggle = $state(false);
@@ -1156,6 +1242,164 @@
1156
1242
  </form>
1157
1243
  </section>
1158
1244
 
1245
+ <section class="card">
1246
+ <form
1247
+ class="row image-row"
1248
+ method="POST"
1249
+ action="?/claudeConfigDir"
1250
+ bind:this={claudeDirFormEl}
1251
+ {@attach enhance(claudeDirOpts)}
1252
+ >
1253
+ <div class="label">
1254
+ <FolderCog size={20} />
1255
+ <div class="text">
1256
+ <div class="name">Claude config directory</div>
1257
+ <div class="desc">
1258
+ Host directory the Claude Code injections read from — credentials, statusLine, output
1259
+ styles, and global skills / <code>CLAUDE.md</code>. Leave blank to use
1260
+ <code>~/.claude</code>. Takes effect for instances created from now on.
1261
+ </div>
1262
+ </div>
1263
+ </div>
1264
+ <div class="image-controls">
1265
+ <input
1266
+ type="text"
1267
+ name="dir"
1268
+ class="image-input"
1269
+ bind:value={claudeDir}
1270
+ spellcheck="false"
1271
+ autocapitalize="off"
1272
+ autocorrect="off"
1273
+ placeholder="~/.claude"
1274
+ />
1275
+ <Button type="submit" disabled={savingClaudeDir}>Save</Button>
1276
+ <Button
1277
+ type="button"
1278
+ icon={RotateCcw}
1279
+ disabled={savingClaudeDir}
1280
+ onclick={resetClaudeDir}
1281
+ title="Reset to default (~/.claude)"
1282
+ aria-label="Reset to default Claude config directory"
1283
+ />
1284
+ </div>
1285
+ {#if claudeDirError}
1286
+ <div class="msg error">{claudeDirError}</div>
1287
+ {:else if claudeDirSaved}
1288
+ <div class="msg ok">Saved.</div>
1289
+ {/if}
1290
+ </form>
1291
+ </section>
1292
+
1293
+ <section class="card">
1294
+ <form class="row" method="POST" action="?/mcpToggle" {@attach enhance(mcpToggleOpts)}>
1295
+ <div class="label">
1296
+ <Plug size={20} />
1297
+ <div class="text">
1298
+ <div class="name">MCP server</div>
1299
+ <div class="desc">
1300
+ Expose Codebay to other AI agents over MCP, so they can create sandboxes, run Claude
1301
+ Code in them non-interactively and read back the result. Off by default; the endpoint
1302
+ 404s until you turn it on, then authenticates with the bearer token below.
1303
+ </div>
1304
+ </div>
1305
+ </div>
1306
+ <label class="switch">
1307
+ <input
1308
+ type="checkbox"
1309
+ name="enabled"
1310
+ checked={mcp}
1311
+ disabled={savingMcpToggle}
1312
+ onchange={(e) => {
1313
+ mcp = e.currentTarget.checked;
1314
+ e.currentTarget.form?.requestSubmit();
1315
+ }}
1316
+ />
1317
+ <span class="track"><span class="thumb"></span></span>
1318
+ </label>
1319
+ </form>
1320
+ {#if mcpToggleError}
1321
+ <div class="sub"><div class="msg error">{mcpToggleError}</div></div>
1322
+ {/if}
1323
+
1324
+ {#if mcp}
1325
+ <div class="row divided">
1326
+ <div class="label">
1327
+ <div class="text">
1328
+ <div class="name">Token</div>
1329
+ <div class="desc">
1330
+ Anything holding this token can create containers and run agents with your GitHub
1331
+ and Claude credentials. Treat it like a password.
1332
+ </div>
1333
+ </div>
1334
+ </div>
1335
+ <div class="model-fields">
1336
+ <label class="model-row">
1337
+ <span class="model-label">Bearer</span>
1338
+ <input
1339
+ type="text"
1340
+ class="image-input"
1341
+ value={mcpTokenValue}
1342
+ readonly
1343
+ spellcheck="false"
1344
+ onfocus={(e) => e.currentTarget.select()}
1345
+ />
1346
+ </label>
1347
+ <div class="mcp-actions">
1348
+ <Button type="button" onclick={() => copyMcp('token', mcpTokenValue)}>
1349
+ {mcpCopied === 'token' ? 'Copied' : 'Copy token'}
1350
+ </Button>
1351
+ <Button type="button" onclick={() => copyMcp('command', mcpCommand)}>
1352
+ {mcpCopied === 'command' ? 'Copied' : 'Copy claude mcp add'}
1353
+ </Button>
1354
+ <form method="POST" action="?/mcpRegenerateToken" {@attach enhance(mcpTokenOpts)}>
1355
+ <Button type="submit" disabled={savingMcpToken}>
1356
+ {savingMcpToken ? 'Regenerating…' : 'Regenerate'}
1357
+ </Button>
1358
+ </form>
1359
+ </div>
1360
+ <code class="mcp-command">{mcpCommand}</code>
1361
+ {#if mcpTokenError}
1362
+ <div class="msg error">{mcpTokenError}</div>
1363
+ {/if}
1364
+ </div>
1365
+ </div>
1366
+
1367
+ <form
1368
+ class="row divided"
1369
+ method="POST"
1370
+ action="?/mcpPrAttributionToggle"
1371
+ {@attach enhance(mcpAttributionOpts)}
1372
+ >
1373
+ <div class="label">
1374
+ <div class="text">
1375
+ <div class="name">Credit Codebay on pull requests</div>
1376
+ <div class="desc">
1377
+ Append a one-line footer to the body of every pull request an agent opens through
1378
+ <code>create_pr</code>. Off by default — an attribution line on your PRs is yours to
1379
+ opt into.
1380
+ </div>
1381
+ </div>
1382
+ </div>
1383
+ <label class="switch">
1384
+ <input
1385
+ type="checkbox"
1386
+ name="enabled"
1387
+ checked={mcpAttribution}
1388
+ disabled={savingMcpAttribution}
1389
+ onchange={(e) => {
1390
+ mcpAttribution = e.currentTarget.checked;
1391
+ e.currentTarget.form?.requestSubmit();
1392
+ }}
1393
+ />
1394
+ <span class="track"><span class="thumb"></span></span>
1395
+ </label>
1396
+ </form>
1397
+ {#if mcpAttributionError}
1398
+ <div class="sub"><div class="msg error">{mcpAttributionError}</div></div>
1399
+ {/if}
1400
+ {/if}
1401
+ </section>
1402
+
1159
1403
  <section class="card">
1160
1404
  <form
1161
1405
  class="row"
@@ -2619,6 +2863,22 @@
2619
2863
  flex: 1;
2620
2864
  min-width: 220px;
2621
2865
  }
2866
+ .mcp-actions {
2867
+ display: flex;
2868
+ flex-wrap: wrap;
2869
+ gap: 6px;
2870
+ }
2871
+ /* The registration line is long and unbreakable, so it scrolls rather than widening the card. */
2872
+ .mcp-command {
2873
+ display: block;
2874
+ overflow-x: auto;
2875
+ white-space: pre;
2876
+ padding: 6px 8px;
2877
+ border: 1px solid var(--edge);
2878
+ background: var(--panel);
2879
+ font-size: 11px;
2880
+ line-height: 1.5;
2881
+ }
2622
2882
  .model-row {
2623
2883
  display: flex;
2624
2884
  align-items: center;
@@ -183,7 +183,8 @@
183
183
  terminal_split: 0,
184
184
  git_branch: cardBranch ? branchName : null,
185
185
  attention: cardAttention,
186
- forwarded_ports: demoPorts
186
+ forwarded_ports: demoPorts,
187
+ active_run: null
187
188
  });
188
189
 
189
190
  const statuses: Instance['status'][] = ['creating', 'running', 'stopped', 'error'];
@@ -234,7 +235,8 @@
234
235
  terminal_split: 0,
235
236
  git_branch: 'main',
236
237
  attention: null,
237
- forwarded_ports: []
238
+ forwarded_ports: [],
239
+ active_run: null
238
240
  };
239
241
  })
240
242
  );
@@ -1,7 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
- import { homedir } from 'node:os';
4
- import { join } from 'node:path';
3
+ import { hostClaudeFile } from '../lib/host-claude.server.ts';
5
4
  import { CLAUDE_CODE_TOKEN, CLAUDE_KEYCHAIN_SERVICE } from '../lib/config.server.ts';
6
5
  import { getOption } from '../lib/db.server.ts';
7
6
  import { checkPresence } from '../lib/exec.server.ts';
@@ -84,7 +83,7 @@ async function locateClaudeCredentials(): Promise<{ creds: string; source: strin
84
83
  }
85
84
  }
86
85
 
87
- const file = join(homedir(), '.claude', '.credentials.json');
86
+ const file = hostClaudeFile('.credentials.json');
88
87
  if (existsSync(file)) {
89
88
  const raw = (await readFile(file, 'utf8')).trim();
90
89
  if (isValid(raw)) return { creds: raw, source: '~/.claude/.credentials.json' };
@@ -0,0 +1,125 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readdir, readFile, stat } from 'node:fs/promises';
3
+ import { posix } from 'node:path';
4
+ import { checkPresence } from '../lib/exec.server.ts';
5
+ import { writeContainerFileBytes } from '../lib/container-files.server.ts';
6
+ import { claudeConfigFile } from '../lib/claude-settings.server.ts';
7
+ import { hostClaudeFile } from '../lib/host-claude.server.ts';
8
+ import type { Injection } from '../lib/injections.server.ts';
9
+
10
+ /**
11
+ * The env-var stdin carrier caps a single write near 128 KB (Linux `MAX_ARG_STRLEN`); base64
12
+ * inflates ~4/3, so a raw file over this is skipped rather than failing the whole injection.
13
+ */
14
+ const MAX_FILE_BYTES = 90_000;
15
+
16
+ interface HostFile {
17
+ /** Path relative to the host Claude dir, e.g. `CLAUDE.md` or `skills/foo/SKILL.md`. */
18
+ rel: string;
19
+ bytes: Buffer;
20
+ /** True when any execute bit is set on the host, so container scripts stay runnable. */
21
+ exec: boolean;
22
+ oversized: boolean;
23
+ }
24
+
25
+ /** Recursively lists every file under a host dir, as `skills/<subpath>` entries. */
26
+ async function walkSkills(dir: string, rel: string, out: HostFile[]): Promise<void> {
27
+ let entries;
28
+ try {
29
+ entries = await readdir(dir, { withFileTypes: true });
30
+ } catch {
31
+ return;
32
+ }
33
+ for (const entry of entries) {
34
+ const abs = posix.join(dir, entry.name);
35
+ const childRel = posix.join(rel, entry.name);
36
+ if (entry.isDirectory()) {
37
+ await walkSkills(abs, childRel, out);
38
+ } else if (entry.isFile()) {
39
+ const info = await stat(abs);
40
+ out.push({
41
+ rel: childRel,
42
+ bytes: info.size > MAX_FILE_BYTES ? Buffer.alloc(0) : await readFile(abs),
43
+ exec: (info.mode & 0o111) !== 0,
44
+ oversized: info.size > MAX_FILE_BYTES
45
+ });
46
+ }
47
+ }
48
+ }
49
+
50
+ /** Cheap presence probe for the setup-UI chip — never reads file contents. */
51
+ export async function hasHostSkillFiles(): Promise<boolean> {
52
+ if (existsSync(hostClaudeFile('CLAUDE.md'))) return true;
53
+ try {
54
+ return (await readdir(hostClaudeFile('skills'))).length > 0;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ /** The host global CLAUDE.md plus every file under the host skills dir. */
61
+ export async function collectHostSkillFiles(): Promise<HostFile[]> {
62
+ const files: HostFile[] = [];
63
+ const claudeMd = hostClaudeFile('CLAUDE.md');
64
+ if (existsSync(claudeMd)) {
65
+ const info = await stat(claudeMd);
66
+ if (info.isFile()) {
67
+ files.push({
68
+ rel: 'CLAUDE.md',
69
+ bytes: info.size > MAX_FILE_BYTES ? Buffer.alloc(0) : await readFile(claudeMd),
70
+ exec: false,
71
+ oversized: info.size > MAX_FILE_BYTES
72
+ });
73
+ }
74
+ }
75
+ await walkSkills(hostClaudeFile('skills'), 'skills', files);
76
+ return files;
77
+ }
78
+
79
+ export const claudeCodeSkills: Injection = {
80
+ id: 'claude-code-skills',
81
+ label: 'skills & CLAUDE.md',
82
+
83
+ auth: {
84
+ hint: 'add skills or a CLAUDE.md to your host ~/.claude',
85
+ async status() {
86
+ const available = await hasHostSkillFiles();
87
+ return { available, source: available ? '~/.claude' : null };
88
+ }
89
+ },
90
+
91
+ async apply(target, log) {
92
+ const files = await collectHostSkillFiles();
93
+ if (!files.length) {
94
+ log('⚠ No global skills or CLAUDE.md found on host; skipped\n');
95
+ return;
96
+ }
97
+ log(`Injecting ${files.length} global skill/CLAUDE.md file(s)…\n`);
98
+ let failed = 0;
99
+ for (const file of files) {
100
+ if (file.oversized) {
101
+ log(`⚠ Skipped ${file.rel} (larger than ${MAX_FILE_BYTES} bytes)\n`);
102
+ continue;
103
+ }
104
+ const dest = claudeConfigFile(file.rel, file.exec ? '755' : '644');
105
+ const wrote = await writeContainerFileBytes(target, dest, file.bytes);
106
+ if (!wrote.ok) {
107
+ failed++;
108
+ log(`⚠ Failed to write ${file.rel}: ${wrote.error}\n`);
109
+ }
110
+ }
111
+ log(
112
+ failed
113
+ ? `⚠ ${failed} skill file(s) failed to inject\n`
114
+ : '✓ Global skills & CLAUDE.md injected\n'
115
+ );
116
+ },
117
+
118
+ async check(target) {
119
+ return checkPresence(
120
+ target,
121
+ 'h=$(eval echo ~$(id -un)); d="${CLAUDE_CONFIG_DIR:-$h/.claude}"; ' +
122
+ 'if [ -s "$d/CLAUDE.md" ] || [ -n "$(ls -A "$d/skills" 2>/dev/null)" ]; then echo 1; else echo 0; fi'
123
+ );
124
+ }
125
+ };
@@ -1,5 +1,4 @@
1
1
  import { readdir, readFile } from 'node:fs/promises';
2
- import { homedir } from 'node:os';
3
2
  import { join } from 'node:path';
4
3
  import { getOption } from '../lib/db.server.ts';
5
4
  import { writeContainerFile } from '../lib/container-files.server.ts';
@@ -8,7 +7,7 @@ import {
8
7
  mergeClaudeSettings,
9
8
  readClaudeSettings
10
9
  } from '../lib/claude-settings.server.ts';
11
- import { readHostClaudeSettings } from './claude-statusline.ts';
10
+ import { hostClaudeFile, readHostClaudeSettings } from '../lib/host-claude.server.ts';
12
11
  import { normalizeOutputStyle } from '../types.ts';
13
12
  import type { ClaudeOutputStyle } from '../types.ts';
14
13
  import type { ContainerTarget, Injection } from '../lib/injections.server.ts';
@@ -29,7 +28,7 @@ export async function resolveEffectiveOutputStyle(): Promise<string | null> {
29
28
 
30
29
  /** The host's custom output-style definitions, so a non-built-in name resolves inside the container. */
31
30
  export async function readHostOutputStyleFiles(): Promise<{ name: string; content: string }[]> {
32
- const dir = join(homedir(), '.claude', 'output-styles');
31
+ const dir = hostClaudeFile('output-styles');
33
32
  let names: string[];
34
33
  try {
35
34
  names = (await readdir(dir)).filter((n) => n.endsWith('.md'));
@@ -1,15 +1,18 @@
1
- import { existsSync, statSync } from 'node:fs';
1
+ import { statSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
- import { homedir } from 'node:os';
4
- import { join } from 'node:path';
3
+ import { isAbsolute } from 'node:path';
5
4
  import { writeContainerFile } from '../lib/container-files.server.ts';
6
5
  import {
7
6
  claudeConfigFile,
8
7
  mergeClaudeSettings,
9
8
  readClaudeSettings
10
9
  } from '../lib/claude-settings.server.ts';
10
+ import { expandTilde, readHostClaudeSettings } from '../lib/host-claude.server.ts';
11
11
  import type { ContainerTarget, Injection } from '../lib/injections.server.ts';
12
12
 
13
+ // Re-exported so the modules and tests that historically imported these from here keep working.
14
+ export { expandTilde, readHostClaudeSettings };
15
+
13
16
  /** Left unexpanded — Claude Code invokes this long after the injection has finished. */
14
17
  const CONTAINER_SCRIPT_PATH = '${CLAUDE_CONFIG_DIR:-$HOME/.claude}/statusline.sh';
15
18
 
@@ -19,19 +22,6 @@ interface StatusLineConfig {
19
22
  script?: string;
20
23
  }
21
24
 
22
- export async function readHostClaudeSettings(): Promise<Record<string, unknown> | null> {
23
- const file = join(homedir(), '.claude', 'settings.json');
24
- if (!existsSync(file)) return null;
25
- try {
26
- return JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown>;
27
- } catch {
28
- return null;
29
- }
30
- }
31
-
32
- export const expandTilde = (p: string): string =>
33
- p.startsWith('~/') ? join(homedir(), p.slice(2)) : p;
34
-
35
25
  /** Only a real file counts — else jq's `//` operator and a bare `/` resolve to the root dir. */
36
26
  const isExistingFile = (p: string): boolean => {
37
27
  try {
@@ -45,7 +35,9 @@ const isExistingFile = (p: string): boolean => {
45
35
  export function extractScriptPath(command: string): string | null {
46
36
  const token = command
47
37
  .split(/\s+/)
48
- .find((t) => (t.startsWith('/') || t.startsWith('~/')) && isExistingFile(expandTilde(t)));
38
+ // isAbsolute rather than a leading slash, so a Windows `C:…` command is recognised too;
39
+ // on POSIX the two are equivalent. The file check still rejects a bare root either way.
40
+ .find((t) => (isAbsolute(t) || t.startsWith('~/')) && isExistingFile(expandTilde(t)));
49
41
  return token ?? null;
50
42
  }
51
43
 
@@ -51,8 +51,19 @@ export function ghHostBlock(raw: string, host: string): string | null {
51
51
  return rest.slice(0, end === -1 ? rest.length : end).join('\n');
52
52
  }
53
53
 
54
+ /** Mirrors `gh`'s own resolution order, which is not XDG on Windows. */
55
+ function ghConfigDir(): string {
56
+ const explicit = process.env.GH_CONFIG_DIR?.trim();
57
+ if (explicit) return explicit;
58
+ if (process.platform === 'win32') {
59
+ return join(process.env.AppData || join(homedir(), 'AppData', 'Roaming'), 'GitHub CLI');
60
+ }
61
+ const xdg = process.env.XDG_CONFIG_HOME?.trim();
62
+ return xdg ? join(xdg, 'gh') : join(homedir(), '.config', 'gh');
63
+ }
64
+
54
65
  async function readGhHostsFile(): Promise<string | null> {
55
- const file = join(homedir(), '.config', 'gh', 'hosts.yml');
66
+ const file = join(ghConfigDir(), 'hosts.yml');
56
67
  if (!existsSync(file)) return null;
57
68
  try {
58
69
  return await readFile(file, 'utf8');
package/src/index.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import { Mochi, sequence, silenceInternalRoutes } from 'mochi-framework';
2
2
  import { routes } from './routes.ts';
3
- import { basicAuth } from './lib/auth.server.ts';
3
+ import { basicAuth, tokenAuthenticated } from './lib/auth.server.ts';
4
4
  import { themeHandle } from './lib/theme.server.ts';
5
5
  import { PROXY_PREFIX } from './lib/proxy.server.ts';
6
+ import { resumeRuns } from './lib/agent-runs.server.ts';
7
+ import { warnIfBuildStale } from './lib/build-freshness.server.ts';
8
+ import { LOCK_STALE_MS, acquireDataDirLock } from './lib/data-dir-lock.server.ts';
6
9
  import {
7
10
  APP_VERSION,
8
11
  BASIC_AUTH_PASSWORD,
12
+ DATA_DIR,
9
13
  HOST,
10
14
  PORT,
11
15
  PUBLIC_ORIGIN,
@@ -21,10 +25,32 @@ if (HOST !== '127.0.0.1' && HOST !== 'localhost' && !BASIC_AUTH_PASSWORD) {
21
25
  `⚠ Binding to ${HOST} (non-loopback) without a password — anyone on the network can reach this server.`
22
26
  );
23
27
  }
28
+ warnIfBuildStale();
29
+
30
+ const lock = acquireDataDirLock(DATA_DIR);
31
+ if (!lock.ok) {
32
+ const who = lock.holder
33
+ ? `pid ${lock.holder.pid} on ${lock.holder.host}, started ${new Date(lock.holder.startedAt).toLocaleString()}`
34
+ : 'an unknown process';
35
+ console.error(
36
+ `✖ Another codebay manager (${who}) is already using ${DATA_DIR}. Two managers on one data dir ` +
37
+ "poll the same runs and overwrite each other's rows — stop it, or start this one with a " +
38
+ `different DATA_DIR. A crashed manager's lock (${lock.path}) clears itself after ${LOCK_STALE_MS / 1000}s.`
39
+ );
40
+ process.exit(1);
41
+ }
42
+ // A default Ctrl-C skips the exit hooks, and an unreleased lock refuses the restart that follows.
43
+ process.on('exit', () => lock.release());
44
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
45
+ process.on(signal, () => process.exit(signal === 'SIGINT' ? 130 : 143));
46
+ }
24
47
 
25
48
  // Must precede Mochi.serve(), which is where MOCHI_KEY is read.
26
49
  ensureMochiKey();
27
50
 
51
+ // Agent runs outlive the process that started them, so re-attach pollers to anything still in flight.
52
+ resumeRuns();
53
+
28
54
  await Mochi.serve({
29
55
  port: PORT,
30
56
  hostname: HOST,
@@ -41,10 +67,9 @@ await Mochi.serve({
41
67
  trustedOrigins: TRUSTED_ORIGINS
42
68
  },
43
69
  filters: {
44
- // Containers curl the bridge with no Origin header, which the check would 403;
45
- // safe because the route authenticates by token, not ambient browser credentials.
46
- 'csrf:check': (decision, { url }) =>
47
- url.pathname.startsWith('/api/bridge/') ? null : decision,
70
+ // Containers and MCP clients send no Origin header, which the check would 403; safe because
71
+ // those routes authenticate by token, not ambient browser credentials.
72
+ 'csrf:check': (decision, { url }) => (tokenAuthenticated(url.pathname) ? null : decision),
48
73
  'consoleLogger:line': (line, ctx) => {
49
74
  const kept = silenceInternalRoutes(line, ctx);
50
75
  if (kept == null) {
@@ -53,8 +78,8 @@ await Mochi.serve({
53
78
  if (ctx.source.name === 'ws:message' && ctx.path.startsWith(PROXY_PREFIX + '/')) {
54
79
  return null;
55
80
  }
56
- // These fire on every Claude hook event and carry the bridge token.
57
- if (ctx.path.startsWith('/api/bridge/')) {
81
+ // A line per Claude hook event or JSON-RPC frame is noise, and each would print its token.
82
+ if (tokenAuthenticated(ctx.path)) {
58
83
  return null;
59
84
  }
60
85
  return kept;