codebay 0.14.0 → 0.16.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 (108) hide show
  1. package/.mochi/manifest.json +778 -646
  2. package/.mochi/svelte-client/HydratableIsland-6y9p903x.js +1 -0
  3. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-nv6yzk0x.js +2 -0
  4. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-ry83ck8a.js +14 -0
  5. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-vjt4jscf.js +3 -0
  6. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-1jdrk8x6.js +48 -0
  7. package/.mochi/svelte-client/{chunk-spk1b2r4.js → chunk-0vqvn81s.js} +1 -1
  8. package/.mochi/svelte-client/chunk-2dc6352a.js +1 -0
  9. package/.mochi/svelte-client/chunk-3cxaw5hr.js +2 -0
  10. package/.mochi/svelte-client/chunk-3jqc40sp.js +2 -0
  11. package/.mochi/svelte-client/chunk-3pgw6t5j.js +4 -0
  12. package/.mochi/svelte-client/chunk-7g3xt6ez.js +2 -0
  13. package/.mochi/svelte-client/chunk-b5s3mpg0.js +1 -0
  14. package/.mochi/svelte-client/chunk-bhseezf3.js +2 -0
  15. package/.mochi/svelte-client/chunk-bhwkq8er.js +1 -0
  16. package/.mochi/svelte-client/chunk-jm3ge348.js +1 -0
  17. package/.mochi/svelte-client/{chunk-0mc09cyb.js → chunk-sxcr9gzp.js} +1 -1
  18. package/.mochi/svelte-client/{chunk-ccfh8hxz.js → chunk-tss2473w.js} +1 -1
  19. package/.mochi/svelte-compile/{App-94xsdn0w.server.js → App-8mpgrxza.server.js} +470 -271
  20. package/.mochi/svelte-compile/{Avatars-fnccw47h.server.js → Avatars-y5nf6q25.server.js} +111 -110
  21. package/.mochi/svelte-compile/{DefaultError-je52k17d.server.js → DefaultError-rrjye0s2.server.js} +1 -2
  22. package/.mochi/svelte-compile/Instance-3sbzyp0g.server.js +695 -0
  23. package/.mochi/svelte-compile/{Settings-a7b11v90.server.js → Settings-ysvh5vhh.server.js} +838 -607
  24. package/.mochi/svelte-compile/chunk-7zaw9t5a.js +25 -0
  25. package/.mochi/svelte-compile/chunk-878xcmp4.js +65 -0
  26. package/.mochi/svelte-compile/chunk-d2z3qxxe.js +28 -0
  27. package/.mochi/svelte-compile/{chunk-60mpagr9.js → chunk-gnf5cnct.js} +661 -22
  28. package/.mochi/svelte-compile/{chunk-w0hca7va.js → chunk-jq7jj8jd.js} +18 -2
  29. package/.mochi/svelte-compile/chunk-vr0e32mv.js +24 -0
  30. package/.mochi/svelte-compile/{chunk-apdte4nh.js → chunk-xmygdsj4.js} +35 -612
  31. package/.mochi/svelte-compile/{chunk-pqb403fh.js → chunk-zk4a9zms.js} +28 -31
  32. package/.mochi/svelte-css/AgentLogBox-38adby5iygny7.css +1 -0
  33. package/.mochi/svelte-css/DropZone-1zeitd761uwyd.css +1 -0
  34. package/.mochi/svelte-css/IdeBar-d3un7kuigxc5.css +1 -0
  35. package/.mochi/svelte-css/{InstanceCard-25z7nas7y98zk.css → InstanceCard-3vxozrp9uoads.css} +1 -1
  36. package/.mochi/svelte-css/InstanceView-3ucotech5097w.css +1 -0
  37. package/.mochi/svelte-css/{SettingsView-bccw34agd2bj.css → SettingsView-2400myxb9ot56.css} +1 -1
  38. package/README.md +43 -3
  39. package/migrations/0009_agent_runs.sql +24 -0
  40. package/migrations/0010_agent_run_model.sql +2 -0
  41. package/migrations/0011_agent_run_rejected_structured_output.sql +2 -0
  42. package/package.json +13 -9
  43. package/src/__fixtures__/IdeBarStrip.svelte +41 -0
  44. package/src/avatars/dog.ts +1 -1
  45. package/src/avatars/lightning.ts +8 -9
  46. package/src/avatars/rocket.ts +4 -5
  47. package/src/components/AgentLogBox.svelte +458 -0
  48. package/src/components/AppShell.svelte +112 -1
  49. package/src/components/DropZone.svelte +77 -0
  50. package/src/components/FolderBrowser.svelte +13 -5
  51. package/src/components/HealthBox.svelte +6 -5
  52. package/src/components/IdeBar.svelte +222 -76
  53. package/src/components/InstanceCard.svelte +38 -0
  54. package/src/components/InstanceView.svelte +83 -2
  55. package/src/components/SettingsView.svelte +273 -0
  56. package/src/components/TerminalPane.svelte +54 -0
  57. package/src/components/UiShowcase.svelte +23 -3
  58. package/src/container-injections/claude-code-install.ts +1 -1
  59. package/src/container-injections/claude-history.ts +52 -0
  60. package/src/container-injections/claude-statusline.ts +4 -1
  61. package/src/container-injections/github-credentials.ts +12 -1
  62. package/src/container-injections/tmux.ts +6 -2
  63. package/src/index.ts +35 -7
  64. package/src/lib/agent-run-stream.ts +286 -0
  65. package/src/lib/agent-runs.server.ts +735 -0
  66. package/src/lib/auth.server.ts +11 -2
  67. package/src/lib/build-freshness.server.ts +71 -0
  68. package/src/lib/config.server.ts +36 -7
  69. package/src/lib/container-files.server.ts +1 -1
  70. package/src/lib/data-dir-lock.server.ts +78 -0
  71. package/src/lib/db.server.ts +124 -1
  72. package/src/lib/devcontainer.server.ts +93 -16
  73. package/src/lib/docker-client.server.ts +11 -5
  74. package/src/lib/drives.ts +11 -0
  75. package/src/lib/exec.server.ts +66 -5
  76. package/src/lib/injections.server.ts +5 -1
  77. package/src/lib/instances.server.ts +66 -7
  78. package/src/lib/log-capture.server.ts +63 -23
  79. package/src/lib/mcp-auth.server.ts +52 -0
  80. package/src/lib/osc52.ts +18 -0
  81. package/src/lib/picker.server.ts +23 -2
  82. package/src/lib/sandbox-ops.server.ts +263 -0
  83. package/src/lib/upload.ts +32 -0
  84. package/src/lib/uploads.server.ts +164 -0
  85. package/src/mcp/routes.server.ts +25 -0
  86. package/src/mcp/server.server.ts +55 -0
  87. package/src/mcp/tools.server.ts +660 -0
  88. package/src/pages/Instance.svelte +6 -2
  89. package/src/pages/Settings.svelte +15 -0
  90. package/src/routes.ts +79 -3
  91. package/src/types.ts +17 -0
  92. package/.mochi/svelte-client/HydratableIsland-zrdsrsmg.js +0 -1
  93. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-v7y7dh53.js +0 -2
  94. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-tmwd6fpb.js +0 -14
  95. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-m7t64dbh.js +0 -1
  96. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-4a7ann1f.js +0 -40
  97. package/.mochi/svelte-client/chunk-5jwfs4p9.js +0 -2
  98. package/.mochi/svelte-client/chunk-6qg4q98a.js +0 -2
  99. package/.mochi/svelte-client/chunk-phf7v5jj.js +0 -4
  100. package/.mochi/svelte-client/chunk-tmeh70wg.js +0 -2
  101. package/.mochi/svelte-client/chunk-vs1yjbqt.js +0 -2
  102. package/.mochi/svelte-client/chunk-x0m3k0rn.js +0 -1
  103. package/.mochi/svelte-client/chunk-y7f4jbdm.js +0 -2
  104. package/.mochi/svelte-compile/Instance-knnkcn2p.server.js +0 -377
  105. package/.mochi/svelte-compile/chunk-ed46m58e.js +0 -22
  106. package/.mochi/svelte-compile/chunk-jx6h1xzp.js +0 -20
  107. package/.mochi/svelte-css/IdeBar-1mtgemqfuvqzi.css +0 -1
  108. package/.mochi/svelte-css/InstanceView-32j12hyg8v9u0.css +0 -1
@@ -3,8 +3,10 @@
3
3
  import ArrowLeft from '@lucide/svelte/icons/arrow-left';
4
4
  import ArrowUpRight from '@lucide/svelte/icons/arrow-up-right';
5
5
  import HealthBox from './HealthBox.svelte';
6
+ import AgentLogBox from './AgentLogBox.svelte';
6
7
  import StatusBadge from './StatusBadge.svelte';
7
8
  import Skeleton from './Skeleton.svelte';
9
+ import DropZone from './DropZone.svelte';
8
10
  import { forwardedPortUrl } from '../lib/links.ts';
9
11
  import { liveSocket, liveStream } from '../live.ts';
10
12
  import { apiPost, apiDelete } from '../api.ts';
@@ -13,13 +15,27 @@
13
15
  import { onBackLinkClick, installPopupBackTrap } from '../lib/popup-nav.ts';
14
16
  import { syncTheme } from '../theme.ts';
15
17
  import { tick } from 'svelte';
18
+ import toast, { Toaster } from 'svelte-french-toast';
19
+ import { TOAST_OPTIONS } from '../toast.ts';
20
+ import { filesFrom, uploadFile } from '../lib/upload.ts';
16
21
 
17
- let { id, injectionChecks = 0 }: { id: string; injectionChecks?: number } = $props();
22
+ let {
23
+ id,
24
+ injectionChecks = 0,
25
+ uploadEnabled = false
26
+ }: { id: string; injectionChecks?: number; uploadEnabled?: boolean } = $props();
27
+
28
+ // DB-backed, so it initializes from the prop; the stream keeps it current across a Settings change.
29
+ // svelte-ignore state_referenced_locally
30
+ let uploadArmed = $state(uploadEnabled);
18
31
 
19
32
  let instance = $state<Instance | null>(null);
20
33
  let health = $state<InstanceHealth | null>(null);
21
34
  let lastFetchedAt = $state<number | null>(null);
22
35
  let logs = $state('');
36
+ // Every `run` frame bumps this; AgentLogBox refetches on the change, so a live run's steps
37
+ // stream in without a second WebSocket.
38
+ let runBump = $state(0);
23
39
  let logEl = $state<HTMLDivElement | null>(null);
24
40
  let following = $state(true);
25
41
 
@@ -62,8 +78,12 @@
62
78
  } else if (msg.type === 'health' && msg.data.id === id) {
63
79
  health = msg.data.health;
64
80
  lastFetchedAt = Date.now();
81
+ } else if (msg.type === 'run' && msg.data.instance_id === id) {
82
+ runBump += 1;
65
83
  } else if (msg.type === 'theme') {
66
84
  syncTheme(msg.data.value);
85
+ } else if (msg.type === 'upload-enabled') {
86
+ uploadArmed = msg.data.enabled;
67
87
  }
68
88
  })
69
89
  );
@@ -140,8 +160,57 @@
140
160
  clearTimeout(copyTimer);
141
161
  copyTimer = setTimeout(() => (copied = false), 2000);
142
162
  }
163
+
164
+ // Sequential, so the toast order matches the drop order.
165
+ async function handleUpload(files: File[]) {
166
+ for (const file of files) {
167
+ try {
168
+ const saved = await uploadFile(id, file);
169
+ toast.success(`Saved ${saved.containerPath}`);
170
+ void navigator.clipboard.writeText(saved.containerPath).catch(() => {});
171
+ } catch (err) {
172
+ toast.error((err as Error).message);
173
+ }
174
+ }
175
+ }
176
+
177
+ let dropping = $state(false);
178
+
179
+ function onDragEnter(e: DragEvent) {
180
+ if (!uploadArmed || !e.dataTransfer?.types.includes('Files')) return;
181
+ e.preventDefault();
182
+ dropping = true;
183
+ }
184
+
185
+ function onDragOver(e: DragEvent) {
186
+ if (dropping) e.preventDefault();
187
+ }
188
+
189
+ function onDragLeave(e: DragEvent) {
190
+ if (e.relatedTarget === null) dropping = false;
191
+ }
192
+
193
+ function onDrop(e: DragEvent) {
194
+ if (!dropping) return;
195
+ e.preventDefault();
196
+ dropping = false;
197
+ if (uploadArmed) void handleUpload(filesFrom(e.dataTransfer));
198
+ }
199
+
200
+ // Only claims pastes that carry files, so text pasted into the page's inputs is untouched.
201
+ function onPaste(e: ClipboardEvent) {
202
+ if (!uploadArmed) return;
203
+ const files = filesFrom(e.clipboardData);
204
+ if (!files.length) return;
205
+ e.preventDefault();
206
+ void handleUpload(files);
207
+ }
143
208
  </script>
144
209
 
210
+ <svelte:window onpaste={onPaste} />
211
+ <!-- Instance renders outside AppShell, which hosts the app-wide Toaster. -->
212
+ <Toaster toastOptions={TOAST_OPTIONS} />
213
+
145
214
  <header class="topbar">
146
215
  <a class="back" href="/" onclick={onBackLinkClick}><ArrowLeft size={15} /> All instances</a>
147
216
  <div class="title">
@@ -165,7 +234,16 @@
165
234
  </header>
166
235
  {#if restartError}<p class="restart-err">{restartError}</p>{/if}
167
236
 
168
- <main class="stage">
237
+ <main
238
+ class="stage"
239
+ ondragenter={onDragEnter}
240
+ ondragover={onDragOver}
241
+ ondragleave={onDragLeave}
242
+ ondrop={onDrop}
243
+ >
244
+ {#if dropping}
245
+ <DropZone />
246
+ {/if}
169
247
  <div class="meta">
170
248
  <span class="k">Source</span>
171
249
  {#if instance}<code>{instance.source_path}</code>{:else}<Skeleton variant="wide" />{/if}
@@ -252,6 +330,8 @@
252
330
  </div>
253
331
  </section>
254
332
 
333
+ <AgentLogBox {id} bump={runBump} />
334
+
255
335
  <div class="logwrap panel">
256
336
  <div class="log-bar panel-bar">
257
337
  <span>Boot log</span>
@@ -366,6 +446,7 @@
366
446
  color: var(--danger);
367
447
  }
368
448
  .stage {
449
+ position: relative;
369
450
  max-width: 1200px;
370
451
  margin: 0 auto;
371
452
  padding: 20px 24px 40px;
@@ -9,6 +9,7 @@
9
9
  import SunMoon from '@lucide/svelte/icons/sun-moon';
10
10
  import ThemePicker from './ThemePicker.svelte';
11
11
  import Layers from '@lucide/svelte/icons/layers';
12
+ import Upload from '@lucide/svelte/icons/upload';
12
13
  import FolderMinus from '@lucide/svelte/icons/folder-minus';
13
14
  import FolderCog from '@lucide/svelte/icons/folder-cog';
14
15
  import Trash2 from '@lucide/svelte/icons/trash-2';
@@ -24,6 +25,7 @@
24
25
  import ListOrdered from '@lucide/svelte/icons/list-ordered';
25
26
  import Boxes from '@lucide/svelte/icons/boxes';
26
27
  import Puzzle from '@lucide/svelte/icons/puzzle';
28
+ import Plug from '@lucide/svelte/icons/plug';
27
29
  import { Toaster } from 'svelte-french-toast';
28
30
  import { TOAST_OPTIONS } from '../toast.ts';
29
31
  import { flushSync } from 'svelte';
@@ -54,6 +56,7 @@
54
56
  import ShieldCheck from '@lucide/svelte/icons/shield-check';
55
57
  import Gauge from '@lucide/svelte/icons/gauge';
56
58
  import MessageSquare from '@lucide/svelte/icons/message-square';
59
+ import ClipboardX from '@lucide/svelte/icons/clipboard-x';
57
60
  import { installPopupBackTrap } from '../lib/popup-nav.ts';
58
61
 
59
62
  /** Every settings form action fails with the same `{ error }` shape. */
@@ -68,9 +71,14 @@
68
71
  defaultImage,
69
72
  builtinImage,
70
73
  disableBuildCache,
74
+ workspaceUploadEnabled,
71
75
  copyIgnorePatterns,
72
76
  builtinCopyIgnore,
73
77
  claudeConfigDir,
78
+ mcpEnabled,
79
+ mcpToken,
80
+ mcpUrl,
81
+ mcpPrAttribution,
74
82
  gitIdentityEnabled,
75
83
  gitIdentityName,
76
84
  gitIdentityEmail,
@@ -110,9 +118,14 @@
110
118
  defaultImage: string;
111
119
  builtinImage: string;
112
120
  disableBuildCache: boolean;
121
+ workspaceUploadEnabled: boolean;
113
122
  copyIgnorePatterns: string;
114
123
  builtinCopyIgnore: string;
115
124
  claudeConfigDir: string;
125
+ mcpEnabled: boolean;
126
+ mcpToken: string;
127
+ mcpUrl: string;
128
+ mcpPrAttribution: boolean;
116
129
  gitIdentityEnabled: boolean;
117
130
  gitIdentityName: string;
118
131
  gitIdentityEmail: string;
@@ -159,6 +172,17 @@
159
172
 
160
173
  $effect(() => installPopupBackTrap());
161
174
 
175
+ /** Only settleable after hydration — SSR has no origin to judge. */
176
+ let insecure = $state<{ origin: string; tunnel: string } | null>(null);
177
+ $effect(() => {
178
+ if (window.isSecureContext) return;
179
+ const port = location.port || '80';
180
+ insecure = {
181
+ origin: location.origin,
182
+ tunnel: `ssh -L ${port}:localhost:${port} ${location.hostname}`
183
+ };
184
+ });
185
+
162
186
  /** Reused by every plain save form below; only the per-control state setters differ. */
163
187
  function saveOpts<Success extends Record<string, unknown> = Record<string, unknown>>(handlers: {
164
188
  setSaving: (v: boolean) => void;
@@ -398,6 +422,57 @@
398
422
  claudeDirFormEl?.requestSubmit();
399
423
  }
400
424
 
425
+ // svelte-ignore state_referenced_locally
426
+ let mcp = $state(mcpEnabled);
427
+ // Unlike every other secret here this one round-trips to the client — a human has to copy it.
428
+ // svelte-ignore state_referenced_locally
429
+ let mcpTokenValue = $state(mcpToken);
430
+ let savingMcpToggle = $state(false);
431
+ let mcpToggleError = $state<string | null>(null);
432
+ let savingMcpToken = $state(false);
433
+ let mcpTokenError = $state<string | null>(null);
434
+ let mcpCopied = $state<string | null>(null);
435
+
436
+ const mcpToggleOpts = toggleOpts<{ enabled: boolean; token: string }>({
437
+ set: (v) => (mcp = v),
438
+ setSaving: (v) => (savingMcpToggle = v),
439
+ setError: (v) => (mcpToggleError = v),
440
+ onSuccess: (data) => (mcpTokenValue = data?.token ?? '')
441
+ });
442
+
443
+ const mcpTokenOpts = saveOpts<{ token: string }>({
444
+ setSaving: (v) => (savingMcpToken = v),
445
+ setError: (v) => (mcpTokenError = v),
446
+ onSuccess: (data) => (mcpTokenValue = data?.token ?? ''),
447
+ confirmMessage:
448
+ 'Regenerate the MCP token? Any agent already configured with the current token will stop working.'
449
+ });
450
+
451
+ // svelte-ignore state_referenced_locally
452
+ let mcpAttribution = $state(mcpPrAttribution);
453
+ let savingMcpAttribution = $state(false);
454
+ let mcpAttributionError = $state<string | null>(null);
455
+
456
+ const mcpAttributionOpts = toggleOpts({
457
+ set: (v) => (mcpAttribution = v),
458
+ setSaving: (v) => (savingMcpAttribution = v),
459
+ setError: (v) => (mcpAttributionError = v)
460
+ });
461
+
462
+ const mcpCommand = $derived(
463
+ `claude mcp add --transport http codebay ${mcpUrl} --header "Authorization: Bearer ${mcpTokenValue}"`
464
+ );
465
+
466
+ async function copyMcp(label: string, text: string) {
467
+ try {
468
+ await navigator.clipboard.writeText(text);
469
+ mcpCopied = label;
470
+ setTimeout(() => (mcpCopied = null), 1500);
471
+ } catch {
472
+ mcpTokenError = 'Could not copy — select the text and copy it manually.';
473
+ }
474
+ }
475
+
401
476
  // svelte-ignore state_referenced_locally
402
477
  let gitIdentity = $state(gitIdentityEnabled);
403
478
  let savingGitToggle = $state(false);
@@ -441,6 +516,18 @@
441
516
  setError: (v) => (cacheError = v)
442
517
  });
443
518
 
519
+ // DB-backed rather than localStorage, so it initializes from the prop.
520
+ // svelte-ignore state_referenced_locally
521
+ let workspaceUpload = $state(workspaceUploadEnabled);
522
+ let savingUpload = $state(false);
523
+ let uploadError = $state<string | null>(null);
524
+
525
+ const uploadToggleOpts = toggleOpts({
526
+ set: (v) => (workspaceUpload = v),
527
+ setSaving: (v) => (savingUpload = v),
528
+ setError: (v) => (uploadError = v)
529
+ });
530
+
444
531
  let clearing = $state(false);
445
532
  let clearMsg = $state<string | null>(null);
446
533
  let clearError = $state<string | null>(null);
@@ -900,6 +987,28 @@
900
987
  </AppBar>
901
988
 
902
989
  <main class="content">
990
+ {#if insecure}
991
+ <section class="card">
992
+ <div class="row">
993
+ <div class="label">
994
+ <ClipboardX size={20} />
995
+ <div class="text">
996
+ <div class="name">Copying out of a terminal may not work on this address</div>
997
+ <div class="desc">
998
+ Browsers hand out clipboard access only on a secure origin, and
999
+ <code>{insecure.origin}</code> isn't one — so a copy out of an instance's VS Code
1000
+ terminal reaches nothing (codebay's own terminal falls back to a legacy copy that
1001
+ usually still works). Either open codebay through <code>localhost</code> (<code
1002
+ >{insecure.tunnel}</code
1003
+ >), or allow this exact origin under
1004
+ <code>chrome://flags/#unsafely-treat-insecure-origin-as-secure</code>.
1005
+ </div>
1006
+ </div>
1007
+ </div>
1008
+ </div>
1009
+ </section>
1010
+ {/if}
1011
+
903
1012
  <section class="card">
904
1013
  <form
905
1014
  class="row"
@@ -1182,6 +1291,44 @@
1182
1291
  </form>
1183
1292
  </section>
1184
1293
 
1294
+ <section class="card">
1295
+ <form
1296
+ class="row"
1297
+ method="POST"
1298
+ action="?/workspaceUploadToggle"
1299
+ {@attach enhance(uploadToggleOpts)}
1300
+ >
1301
+ <div class="label">
1302
+ <Upload size={20} />
1303
+ <div class="text">
1304
+ <div class="name">Drop files into the workspace</div>
1305
+ <div class="desc">
1306
+ Drag-and-drop or paste files and images onto an instance to save them under
1307
+ <code>codebay-inbox/</code> in its workspace (git-excluded). The in-container path is shown
1308
+ so Claude can Read it. Turn off to drop straight into VS Code's explorer instead. Off by
1309
+ default.
1310
+ </div>
1311
+ </div>
1312
+ </div>
1313
+ <label class="switch">
1314
+ <input
1315
+ type="checkbox"
1316
+ name="enabled"
1317
+ checked={workspaceUpload}
1318
+ disabled={savingUpload}
1319
+ onchange={(e) => {
1320
+ workspaceUpload = e.currentTarget.checked;
1321
+ e.currentTarget.form?.requestSubmit();
1322
+ }}
1323
+ />
1324
+ <span class="track"><span class="thumb"></span></span>
1325
+ </label>
1326
+ </form>
1327
+ {#if uploadError}
1328
+ <div class="sub"><div class="msg error">{uploadError}</div></div>
1329
+ {/if}
1330
+ </section>
1331
+
1185
1332
  <section class="card">
1186
1333
  <form
1187
1334
  class="row image-row"
@@ -1230,6 +1377,116 @@
1230
1377
  </form>
1231
1378
  </section>
1232
1379
 
1380
+ <section class="card">
1381
+ <form class="row" method="POST" action="?/mcpToggle" {@attach enhance(mcpToggleOpts)}>
1382
+ <div class="label">
1383
+ <Plug size={20} />
1384
+ <div class="text">
1385
+ <div class="name">MCP server</div>
1386
+ <div class="desc">
1387
+ Expose Codebay to other AI agents over MCP, so they can create sandboxes, run Claude
1388
+ Code in them non-interactively and read back the result. Off by default; the endpoint
1389
+ 404s until you turn it on, then authenticates with the bearer token below.
1390
+ </div>
1391
+ </div>
1392
+ </div>
1393
+ <label class="switch">
1394
+ <input
1395
+ type="checkbox"
1396
+ name="enabled"
1397
+ checked={mcp}
1398
+ disabled={savingMcpToggle}
1399
+ onchange={(e) => {
1400
+ mcp = e.currentTarget.checked;
1401
+ e.currentTarget.form?.requestSubmit();
1402
+ }}
1403
+ />
1404
+ <span class="track"><span class="thumb"></span></span>
1405
+ </label>
1406
+ </form>
1407
+ {#if mcpToggleError}
1408
+ <div class="sub"><div class="msg error">{mcpToggleError}</div></div>
1409
+ {/if}
1410
+
1411
+ {#if mcp}
1412
+ <div class="row divided">
1413
+ <div class="label">
1414
+ <div class="text">
1415
+ <div class="name">Token</div>
1416
+ <div class="desc">
1417
+ Anything holding this token can create containers and run agents with your GitHub
1418
+ and Claude credentials. Treat it like a password.
1419
+ </div>
1420
+ </div>
1421
+ </div>
1422
+ <div class="model-fields">
1423
+ <label class="model-row">
1424
+ <span class="model-label">Bearer</span>
1425
+ <input
1426
+ type="text"
1427
+ class="image-input"
1428
+ value={mcpTokenValue}
1429
+ readonly
1430
+ spellcheck="false"
1431
+ onfocus={(e) => e.currentTarget.select()}
1432
+ />
1433
+ </label>
1434
+ <div class="mcp-actions">
1435
+ <Button type="button" onclick={() => copyMcp('token', mcpTokenValue)}>
1436
+ {mcpCopied === 'token' ? 'Copied' : 'Copy token'}
1437
+ </Button>
1438
+ <Button type="button" onclick={() => copyMcp('command', mcpCommand)}>
1439
+ {mcpCopied === 'command' ? 'Copied' : 'Copy claude mcp add'}
1440
+ </Button>
1441
+ <form method="POST" action="?/mcpRegenerateToken" {@attach enhance(mcpTokenOpts)}>
1442
+ <Button type="submit" disabled={savingMcpToken}>
1443
+ {savingMcpToken ? 'Regenerating…' : 'Regenerate'}
1444
+ </Button>
1445
+ </form>
1446
+ </div>
1447
+ <code class="mcp-command">{mcpCommand}</code>
1448
+ {#if mcpTokenError}
1449
+ <div class="msg error">{mcpTokenError}</div>
1450
+ {/if}
1451
+ </div>
1452
+ </div>
1453
+
1454
+ <form
1455
+ class="row divided"
1456
+ method="POST"
1457
+ action="?/mcpPrAttributionToggle"
1458
+ {@attach enhance(mcpAttributionOpts)}
1459
+ >
1460
+ <div class="label">
1461
+ <div class="text">
1462
+ <div class="name">Credit Codebay on pull requests</div>
1463
+ <div class="desc">
1464
+ Append a one-line footer to the body of every pull request an agent opens through
1465
+ <code>create_pr</code>. Off by default — an attribution line on your PRs is yours to
1466
+ opt into.
1467
+ </div>
1468
+ </div>
1469
+ </div>
1470
+ <label class="switch">
1471
+ <input
1472
+ type="checkbox"
1473
+ name="enabled"
1474
+ checked={mcpAttribution}
1475
+ disabled={savingMcpAttribution}
1476
+ onchange={(e) => {
1477
+ mcpAttribution = e.currentTarget.checked;
1478
+ e.currentTarget.form?.requestSubmit();
1479
+ }}
1480
+ />
1481
+ <span class="track"><span class="thumb"></span></span>
1482
+ </label>
1483
+ </form>
1484
+ {#if mcpAttributionError}
1485
+ <div class="sub"><div class="msg error">{mcpAttributionError}</div></div>
1486
+ {/if}
1487
+ {/if}
1488
+ </section>
1489
+
1233
1490
  <section class="card">
1234
1491
  <form
1235
1492
  class="row"
@@ -2693,6 +2950,22 @@
2693
2950
  flex: 1;
2694
2951
  min-width: 220px;
2695
2952
  }
2953
+ .mcp-actions {
2954
+ display: flex;
2955
+ flex-wrap: wrap;
2956
+ gap: 6px;
2957
+ }
2958
+ /* The registration line is long and unbreakable, so it scrolls rather than widening the card. */
2959
+ .mcp-command {
2960
+ display: block;
2961
+ overflow-x: auto;
2962
+ white-space: pre;
2963
+ padding: 6px 8px;
2964
+ border: 1px solid var(--edge);
2965
+ background: var(--panel);
2966
+ font-size: 11px;
2967
+ line-height: 1.5;
2968
+ }
2696
2969
  .model-row {
2697
2970
  display: flex;
2698
2971
  align-items: center;
@@ -4,6 +4,7 @@
4
4
  import type { Terminal } from '@xterm/xterm';
5
5
  import type { FitAddon } from '@xterm/addon-fit';
6
6
  import RotateCw from '@lucide/svelte/icons/rotate-cw';
7
+ import { decodeOsc52 } from '../lib/osc52.ts';
7
8
 
8
9
  let {
9
10
  id,
@@ -118,6 +119,40 @@
118
119
  }
119
120
  }
120
121
 
122
+ /**
123
+ * The Clipboard API is the only path to the *host* clipboard, but it needs a secure context and
124
+ * (outside a user gesture) can still reject — hence the legacy textarea fallback behind it.
125
+ */
126
+ async function toClipboard(text: string) {
127
+ if (!text) return;
128
+ try {
129
+ await navigator.clipboard.writeText(text);
130
+ return;
131
+ } catch {
132
+ /* no Clipboard API, or a write the browser refused — fall through */
133
+ }
134
+ const ta = document.createElement('textarea');
135
+ ta.value = text;
136
+ ta.setAttribute('aria-hidden', 'true');
137
+ ta.style.position = 'fixed';
138
+ ta.style.opacity = '0';
139
+ document.body.appendChild(ta);
140
+ ta.select();
141
+ try {
142
+ document.execCommand('copy');
143
+ } catch {
144
+ /* nothing left to try */
145
+ }
146
+ ta.remove();
147
+ if (active && focus) term?.focus();
148
+ }
149
+
150
+ /** A finished drag is both the moment the selection exists and the gesture the clipboard needs. */
151
+ function copySelection() {
152
+ const sel = term?.getSelection();
153
+ if (sel) void toClipboard(sel);
154
+ }
155
+
121
156
  function fitSafe() {
122
157
  // A hidden pane (display:none) has no size, so fitting there would throw or size to 0.
123
158
  if (el?.clientWidth > 0 && el?.clientHeight > 0) {
@@ -165,6 +200,21 @@
165
200
  term.open(el);
166
201
  applyTheme();
167
202
  fitSafe();
203
+ // Without this, tmux's `set-clipboard on` writes land in a handler xterm doesn't have.
204
+ term.parser.registerOscHandler(52, (data) => {
205
+ const text = decodeOsc52(data);
206
+ if (text !== null) void toClipboard(text);
207
+ return true;
208
+ });
209
+ // Chrome keeps Ctrl+Shift+C for devtools, so a browser terminal needs its own copy key.
210
+ term.attachCustomKeyEventHandler((e) => {
211
+ if (e.type !== 'keydown' || !term?.hasSelection()) return true;
212
+ const isCopy =
213
+ e.ctrlKey && (e.key === 'Insert' || (e.altKey && e.key.toLowerCase() === 'c'));
214
+ if (!isCopy) return true;
215
+ copySelection();
216
+ return false;
217
+ });
168
218
  term.onData((d) => send(CMD_INPUT + d));
169
219
  term.onResize(({ cols, rows }) => send(CMD_RESIZE + JSON.stringify({ columns: cols, rows })));
170
220
  // The theme cookie flips data-theme on <html>; re-derive xterm's colors when it does.
@@ -182,8 +232,12 @@
182
232
  const sizeObs = new ResizeObserver(() => fitSafe());
183
233
  sizeObs.observe(el);
184
234
 
235
+ // Shift+drag selects in xterm even while tmux owns the mouse; copy it before a repaint clears it.
236
+ el.addEventListener('mouseup', copySelection);
237
+
185
238
  return () => {
186
239
  disposed = true;
240
+ el.removeEventListener('mouseup', copySelection);
187
241
  sizeObs.disconnect();
188
242
  themeObs?.disconnect();
189
243
  scheme.removeEventListener('change', onScheme);
@@ -14,6 +14,7 @@
14
14
  import SettingsCog from './SettingsCog.svelte';
15
15
  import TopBar from './TopBar.svelte';
16
16
  import IdeLoader from './IdeLoader.svelte';
17
+ import DropZone from './DropZone.svelte';
17
18
  import HealthBox from './HealthBox.svelte';
18
19
  import BranchBox from './BranchBox.svelte';
19
20
  import PortsBox from './PortsBox.svelte';
@@ -183,7 +184,8 @@
183
184
  terminal_split: 0,
184
185
  git_branch: cardBranch ? branchName : null,
185
186
  attention: cardAttention,
186
- forwarded_ports: demoPorts
187
+ forwarded_ports: demoPorts,
188
+ active_run: null
187
189
  });
188
190
 
189
191
  const statuses: Instance['status'][] = ['creating', 'running', 'stopped', 'error'];
@@ -212,6 +214,8 @@
212
214
  let tabEditingName = $state('');
213
215
  // Renames are applied for real, so the inline editor is inspectable end to end.
214
216
  let tabRenames = $state<Record<string, string>>({});
217
+ // No container behind the demo, so a stopped tab just stays disabled.
218
+ let tabStopping = $state<string[]>([]);
215
219
 
216
220
  const demoTabs = $derived(
217
221
  Array.from({ length: tabCount }, (_, i): Instance => {
@@ -234,7 +238,8 @@
234
238
  terminal_split: 0,
235
239
  git_branch: 'main',
236
240
  attention: null,
237
- forwarded_ports: []
241
+ forwarded_ports: [],
242
+ active_run: null
238
243
  };
239
244
  })
240
245
  );
@@ -461,6 +466,15 @@
461
466
  {/snippet}
462
467
  </ComponentDemo>
463
468
 
469
+ <ComponentDemo
470
+ title="DropZone"
471
+ note="Shown while a file drag is over an instance; the page decides when."
472
+ >
473
+ <div class="loader-stage">
474
+ <DropZone />
475
+ </div>
476
+ </ComponentDemo>
477
+
464
478
  <ComponentDemo title="HealthBox">
465
479
  <HealthBox
466
480
  health={demoHealth}
@@ -553,6 +567,12 @@
553
567
  bind:editingName={tabEditingName}
554
568
  onreload={() => toast('Reload editor (demo)')}
555
569
  onselect={(id) => (tabActive = demoTabs.findIndex((t) => t.id === id))}
570
+ stopping={tabStopping}
571
+ onstop={(id) => {
572
+ tabStopping = [...tabStopping, id];
573
+ toast(`Stop ${id} (demo)`);
574
+ }}
575
+ ondropfiles={(id, files) => toast(`Drop ${files.length} file(s) on ${id} (demo)`)}
556
576
  onstartrename={(inst) => {
557
577
  tabEditingId = inst.id;
558
578
  tabEditingName = inst.name;
@@ -675,7 +695,7 @@
675
695
  .showcase :global(.fields input[type='checkbox']) {
676
696
  accent-color: var(--ink);
677
697
  }
678
- /* IdeLoader is an inset:0 overlay, so give it a sized, positioned stage. */
698
+ /* IdeLoader and DropZone are inset:0 overlays, so give them a sized, positioned stage. */
679
699
  .loader-stage {
680
700
  position: relative;
681
701
  width: 100%;
@@ -49,7 +49,7 @@ const CHECK_SCRIPT = 'command -v claude >/dev/null 2>&1 && echo 1 || echo 0';
49
49
  */
50
50
  export const claudeCodeInstall: Injection = {
51
51
  id: 'claude-code-install',
52
- label: 'Claude Code',
52
+ label: 'Claude Code installed',
53
53
  modes: ['terminal'],
54
54
 
55
55
  async apply(target, log) {