codebay 0.7.0 → 0.9.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 (82) hide show
  1. package/.mochi/import-css/xterm-1yy1hf1g.css +252 -0
  2. package/.mochi/manifest.json +326 -207
  3. package/.mochi/svelte-client/HydratableIsland-k6q8esj4.js +1 -0
  4. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-sg96rp17.js +2 -0
  5. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-92hepzby.js +14 -0
  6. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-p3f649gk.js +1 -0
  7. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-dzmgmv78.js +29 -0
  8. package/.mochi/svelte-client/chunk-2j95sv83.js +1 -0
  9. package/.mochi/svelte-client/chunk-6rbs0mhx.js +1 -0
  10. package/.mochi/svelte-client/chunk-7wkvn2vx.js +2 -0
  11. package/.mochi/svelte-client/chunk-a2psjmrm.js +2 -0
  12. package/.mochi/svelte-client/chunk-m497q88s.js +2 -0
  13. package/.mochi/svelte-client/{chunk-fqg2qzj8.js → chunk-m4fzsd5b.js} +1 -1
  14. package/.mochi/svelte-client/chunk-mpv3qx28.js +36 -0
  15. package/.mochi/svelte-client/chunk-rs41by6w.js +2 -0
  16. package/.mochi/svelte-client/{chunk-qj7zy2d9.js → chunk-v65n8th6.js} +4 -4
  17. package/.mochi/svelte-compile/{App-6rcf0d74.server.js → App-7vjcp38v.server.js} +368 -37
  18. package/.mochi/svelte-compile/{Avatars-pzyz0tv3.server.js → Avatars-e2cfc95t.server.js} +130 -25
  19. package/.mochi/svelte-compile/{ClientStats-z3tn9ad6.server.js → ClientStats-4cphmrnp.server.js} +1 -1
  20. package/.mochi/svelte-compile/{DefaultError-wh77vebf.server.js → DefaultError-je52k17d.server.js} +1 -1
  21. package/.mochi/svelte-compile/{Instance-49ksrp11.server.js → Instance-h78n2dsv.server.js} +16 -13
  22. package/.mochi/svelte-compile/{Settings-sz5d6qj7.server.js → Settings-tav2asn7.server.js} +153 -127
  23. package/.mochi/svelte-compile/{chunk-fpe16ywq.js → chunk-60mpagr9.js} +17 -7
  24. package/.mochi/svelte-compile/chunk-9nqc2r0y.js +9706 -0
  25. package/.mochi/svelte-compile/{chunk-6ghzacpg.js → chunk-bpqwf1nw.js} +36 -4
  26. package/.mochi/svelte-compile/chunk-jx6h1xzp.js +20 -0
  27. package/.mochi/svelte-compile/{chunk-q8rbesaw.js → chunk-m8ygpyhr.js} +5 -4
  28. package/.mochi/svelte-compile/chunk-rtdne1m8.js +31 -0
  29. package/.mochi/svelte-compile/{chunk-we0ggtws.js → chunk-xpt1a7jm.js} +2 -1
  30. package/.mochi/svelte-css/AvatarEditor-2mdq8ss346drw.css +1 -0
  31. package/.mochi/svelte-css/{AvatarsView-2ughbzc26luyb.css → AvatarsView-2llo48w0wt0xn.css} +1 -1
  32. package/.mochi/svelte-css/FolderBrowser-2pxp9z1o7zixk.css +1 -0
  33. package/.mochi/svelte-css/{IdeBar-2qoqi2ya9zdn3.css → IdeBar-10xodudcx57qr.css} +1 -1
  34. package/.mochi/svelte-css/{SettingsView-3uycxa8z548m4.css → SettingsView-2qfauz68axro1.css} +1 -1
  35. package/.mochi/svelte-css/TerminalPane-3bwgk6ulzq6b3.css +1 -0
  36. package/.mochi/svelte-css/TerminalSplit-2416xfuln89a3.css +1 -0
  37. package/README.md +3 -0
  38. package/migrations/0005_instance_mode.sql +1 -0
  39. package/migrations/0006_terminal_split.sql +1 -0
  40. package/package.json +3 -1
  41. package/src/avatars/serialize.ts +35 -6
  42. package/src/components/AppShell.svelte +46 -8
  43. package/src/components/Avatar.svelte +3 -2
  44. package/src/components/AvatarEditor.svelte +96 -19
  45. package/src/components/AvatarsView.svelte +0 -0
  46. package/src/components/DashboardView.svelte +22 -7
  47. package/src/components/FolderBrowser.svelte +88 -8
  48. package/src/components/HealthBox.svelte +12 -5
  49. package/src/components/IdeBar.svelte +10 -0
  50. package/src/components/InstanceCard.svelte +3 -1
  51. package/src/components/InstanceView.svelte +1 -0
  52. package/src/components/SettingsView.svelte +108 -0
  53. package/src/components/TerminalPane.svelte +274 -0
  54. package/src/components/TerminalSplit.svelte +154 -0
  55. package/src/container-injections/claude-code-ide-extension.ts +2 -0
  56. package/src/container-injections/claude-code-update.ts +43 -0
  57. package/src/container-injections/ttyd.ts +49 -0
  58. package/src/index.ts +2 -1
  59. package/src/lib/config.server.ts +3 -0
  60. package/src/lib/db.server.ts +14 -4
  61. package/src/lib/devcontainer.server.ts +140 -24
  62. package/src/lib/health.server.ts +1 -1
  63. package/src/lib/injections.server.ts +18 -5
  64. package/src/lib/instances.server.ts +64 -10
  65. package/src/lib/proxy.server.ts +87 -11
  66. package/src/pages/App.svelte +5 -3
  67. package/src/pages/Settings.svelte +4 -0
  68. package/src/routes.ts +55 -6
  69. package/src/types.ts +18 -0
  70. package/.mochi/svelte-client/HydratableIsland-qyjx1d98.js +0 -1
  71. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-fkna7bj2.js +0 -2
  72. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-89wtkb86.js +0 -13
  73. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-eamnbyqk.js +0 -1
  74. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-kdra4mcb.js +0 -28
  75. package/.mochi/svelte-client/chunk-330mrspw.js +0 -2
  76. package/.mochi/svelte-client/chunk-4jgkak13.js +0 -1
  77. package/.mochi/svelte-client/chunk-k5m4kt20.js +0 -2
  78. package/.mochi/svelte-client/chunk-rpdphch7.js +0 -2
  79. package/.mochi/svelte-compile/chunk-bacb4b0r.js +0 -14
  80. package/.mochi/svelte-css/AvatarEditor-2ewwtejaml4e2.css +0 -1
  81. package/.mochi/svelte-css/FolderBrowser-1x428zb3f2kti.css +0 -1
  82. /package/.mochi/svelte-compile/{chunk-btmcxhqy.js → chunk-brjv9pyd.js} +0 -0
@@ -0,0 +1,274 @@
1
+ <script lang="ts">
2
+ import { onMount, type Snippet } from 'svelte';
3
+ import '@xterm/xterm/css/xterm.css';
4
+ import type { Terminal } from '@xterm/xterm';
5
+ import type { FitAddon } from '@xterm/addon-fit';
6
+ import RotateCw from '@lucide/svelte/icons/rotate-cw';
7
+
8
+ let {
9
+ id,
10
+ active,
11
+ /** ttyd `--url-arg` value selecting which session to attach to; omitted means Claude's. */
12
+ arg,
13
+ /** Split view: only one of the two panes may claim the keyboard when the tab activates. */
14
+ focus = true,
15
+ /** Extra overlay buttons, rendered left of this pane's own reload button. */
16
+ actions
17
+ }: {
18
+ id: string;
19
+ active: boolean;
20
+ arg?: string;
21
+ focus?: boolean;
22
+ actions?: Snippet;
23
+ } = $props();
24
+
25
+ let el: HTMLDivElement;
26
+ let term: Terminal | undefined;
27
+ let fit: FitAddon | undefined;
28
+ let ws: WebSocket | undefined;
29
+ let retry: ReturnType<typeof setTimeout> | undefined;
30
+ let disposed = false;
31
+ let connected = $state(false);
32
+ // Bumped whenever a socket is abandoned, so its late handlers can't resurrect a retry loop.
33
+ let gen = 0;
34
+
35
+ // ttyd's wire protocol: a single command byte prefixes every frame, sent as binary
36
+ // (matching ttyd's own client — libwebsockets delivers the raw bytes to the server).
37
+ const CMD_INPUT = '0';
38
+ const CMD_RESIZE = '1';
39
+ const OUTPUT = 0x30; // '0'
40
+ const encoder = new TextEncoder();
41
+
42
+ function send(data: string) {
43
+ if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data));
44
+ }
45
+
46
+ function onFrame(data: ArrayBuffer) {
47
+ const bytes = new Uint8Array(data);
48
+ if (bytes.length === 0) return;
49
+ // Only OUTPUT carries terminal bytes; SET_WINDOW_TITLE/SET_PREFERENCES are ignored.
50
+ if (bytes[0] === OUTPUT) term?.write(bytes.subarray(1));
51
+ }
52
+
53
+ function connect() {
54
+ const myGen = ++gen;
55
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
56
+ // Same-origin through the proxy, which forwards the required `tty` subprotocol to ttyd
57
+ // and passes the query string through untouched.
58
+ const query = arg ? `?arg=${encodeURIComponent(arg)}` : '';
59
+ const sock = new WebSocket(`${proto}//${location.host}/p/${id}/ws${query}`, ['tty']);
60
+ ws = sock;
61
+ sock.binaryType = 'arraybuffer';
62
+ sock.onopen = () => {
63
+ if (gen !== myGen) return;
64
+ connected = true;
65
+ // ttyd's init frame is raw JSON (its leading `{` is the JSON_DATA command byte).
66
+ send(JSON.stringify({ AuthToken: '', columns: term?.cols ?? 80, rows: term?.rows ?? 24 }));
67
+ };
68
+ sock.onmessage = (e) => {
69
+ if (gen === myGen) onFrame(e.data as ArrayBuffer);
70
+ };
71
+ sock.onclose = () => {
72
+ if (gen !== myGen || disposed) return;
73
+ connected = false;
74
+ retry = setTimeout(connect, 1000);
75
+ };
76
+ sock.onerror = () => {
77
+ try {
78
+ sock.close();
79
+ } catch {
80
+ /* already closing */
81
+ }
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Rebuild only the client half — the socket and xterm's buffer — and reattach to the same tmux
87
+ * session, so a wedged socket (dead wifi, resumed laptop) recovers without touching the container.
88
+ */
89
+ function reload() {
90
+ if (retry) clearTimeout(retry);
91
+ gen++;
92
+ connected = false;
93
+ // tmux repaints the current screen on attach; without a reset that repaint lands under stale output.
94
+ term?.reset();
95
+ const old = ws;
96
+ ws = undefined;
97
+
98
+ let started = false;
99
+ const start = () => {
100
+ if (started || disposed) return;
101
+ started = true;
102
+ fitSafe();
103
+ connect();
104
+ if (active && focus) term?.focus();
105
+ };
106
+
107
+ if (old && old.readyState !== WebSocket.CLOSED) {
108
+ // Let the old tmux client detach first, or tmux sizes the window to the smaller of the two.
109
+ old.addEventListener('close', start, { once: true });
110
+ setTimeout(start, 1500);
111
+ try {
112
+ old.close();
113
+ } catch {
114
+ /* already closing */
115
+ }
116
+ } else {
117
+ start();
118
+ }
119
+ }
120
+
121
+ function fitSafe() {
122
+ // A hidden pane (display:none) has no size, so fitting there would throw or size to 0.
123
+ if (el?.clientWidth > 0 && el?.clientHeight > 0) {
124
+ try {
125
+ fit?.fit();
126
+ } catch {
127
+ /* transient zero-size during layout */
128
+ }
129
+ }
130
+ }
131
+
132
+ function applyTheme() {
133
+ if (!term) return;
134
+ const cs = getComputedStyle(document.documentElement);
135
+ const bg = cs.getPropertyValue('--bg').trim() || '#1a1a1a';
136
+ const fg = cs.getPropertyValue('--ink').trim() || '#e6e6e6';
137
+ term.options.theme = { background: bg, foreground: fg, cursor: fg, cursorAccent: bg };
138
+ }
139
+
140
+ onMount(() => {
141
+ let themeObs: MutationObserver | undefined;
142
+ (async () => {
143
+ const [{ Terminal }, { FitAddon }] = await Promise.all([
144
+ import('@xterm/xterm'),
145
+ import('@xterm/addon-fit')
146
+ ]);
147
+ if (disposed) return;
148
+ term = new Terminal({
149
+ fontFamily: "'JetBrains Mono Variable', ui-monospace, monospace",
150
+ fontSize: 13,
151
+ cursorBlink: true,
152
+ scrollback: 10000,
153
+ allowProposedApi: true
154
+ });
155
+ fit = new FitAddon();
156
+ term.loadAddon(fit);
157
+ term.open(el);
158
+ applyTheme();
159
+ fitSafe();
160
+ term.onData((d) => send(CMD_INPUT + d));
161
+ term.onResize(({ cols, rows }) => send(CMD_RESIZE + JSON.stringify({ columns: cols, rows })));
162
+ // The theme cookie flips data-theme on <html>; re-derive xterm's colors when it does.
163
+ themeObs = new MutationObserver(applyTheme);
164
+ themeObs.observe(document.documentElement, {
165
+ attributes: true,
166
+ attributeFilter: ['data-theme']
167
+ });
168
+ connect();
169
+ if (active && focus) term.focus();
170
+ })();
171
+
172
+ // Observing the element rather than the window also covers split-divider drags and the
173
+ // split opening or closing, which don't resize the window at all.
174
+ const sizeObs = new ResizeObserver(() => fitSafe());
175
+ sizeObs.observe(el);
176
+
177
+ return () => {
178
+ disposed = true;
179
+ sizeObs.disconnect();
180
+ themeObs?.disconnect();
181
+ if (retry) clearTimeout(retry);
182
+ try {
183
+ ws?.close();
184
+ } catch {
185
+ /* already closing */
186
+ }
187
+ term?.dispose();
188
+ };
189
+ });
190
+
191
+ // A hidden xterm can't measure itself, so refit/focus the moment this pane becomes active.
192
+ $effect(() => {
193
+ if (active && term) {
194
+ fitSafe();
195
+ if (focus) term.focus();
196
+ }
197
+ });
198
+ </script>
199
+
200
+ <div class="term" bind:this={el}></div>
201
+ <div class="overlay">
202
+ {#if !connected}
203
+ <span class="status">connecting…</span>
204
+ {/if}
205
+ {@render actions?.()}
206
+ <button
207
+ type="button"
208
+ onclick={reload}
209
+ title="Reload terminal (reconnects, keeps the session)"
210
+ aria-label="Reload terminal"><RotateCw size={14} /></button
211
+ >
212
+ </div>
213
+
214
+ <style>
215
+ .term {
216
+ width: 100%;
217
+ height: 100%;
218
+ background: var(--bg);
219
+ padding: 6px 8px;
220
+ box-sizing: border-box;
221
+ }
222
+ /* xterm's viewport should own the scroll, not the pane. */
223
+ .term :global(.xterm),
224
+ .term :global(.xterm-viewport) {
225
+ height: 100%;
226
+ }
227
+ /* Floats over live terminal output, so only the button itself takes clicks. */
228
+ .overlay {
229
+ position: absolute;
230
+ top: 6px;
231
+ right: 8px;
232
+ display: flex;
233
+ align-items: center;
234
+ gap: 8px;
235
+ pointer-events: none;
236
+ }
237
+ .status {
238
+ font-family: var(--font-mono);
239
+ font-size: 11px;
240
+ letter-spacing: 0.05em;
241
+ text-transform: uppercase;
242
+ color: var(--ink-faint);
243
+ }
244
+ /* `:global` so buttons handed in through the `actions` snippet — which carry the parent
245
+ component's scope, not ours — pick up the same styling. */
246
+ .overlay :global(button) {
247
+ pointer-events: auto;
248
+ appearance: none;
249
+ display: inline-flex;
250
+ align-items: center;
251
+ justify-content: center;
252
+ width: 24px;
253
+ height: 24px;
254
+ padding: 0;
255
+ cursor: pointer;
256
+ background: var(--bg);
257
+ border: 1px solid var(--rule);
258
+ color: var(--ink);
259
+ opacity: 0.3;
260
+ transition: opacity 0.15s ease;
261
+ }
262
+ .overlay :global(button:hover),
263
+ .overlay :global(button:focus-visible),
264
+ .overlay :global(button[aria-pressed='true']) {
265
+ opacity: 1;
266
+ background: var(--ink);
267
+ color: var(--bg);
268
+ }
269
+ @media (prefers-reduced-motion: reduce) {
270
+ .overlay :global(button) {
271
+ transition: none;
272
+ }
273
+ }
274
+ </style>
@@ -0,0 +1,154 @@
1
+ <script lang="ts">
2
+ import PanelRightOpen from '@lucide/svelte/icons/panel-right-open';
3
+ import PanelRightClose from '@lucide/svelte/icons/panel-right-close';
4
+ import TerminalPane from './TerminalPane.svelte';
5
+ import { apiPost } from '../api.ts';
6
+
7
+ let {
8
+ id,
9
+ active,
10
+ initialOpen = false
11
+ }: { id: string; active: boolean; initialOpen?: boolean } = $props();
12
+
13
+ // Seeding from the persisted flag is intentional — from here on the toggle owns the state.
14
+ // svelte-ignore state_referenced_locally
15
+ let open = $state(initialOpen);
16
+ // Once mounted the shell pane is only ever hidden, never torn down, so its xterm buffer and
17
+ // socket survive a close/reopen.
18
+ // svelte-ignore state_referenced_locally
19
+ let mounted = $state(initialOpen);
20
+ let ratio = $state(50);
21
+ let focusSide = $state<'left' | 'right'>('left');
22
+ let container: HTMLDivElement;
23
+
24
+ function toggle() {
25
+ open = !open;
26
+ if (open) mounted = true;
27
+ focusSide = open ? 'right' : 'left';
28
+ // Best-effort: a failed write only costs the restore-on-reload, never the split itself.
29
+ void apiPost(`/api/instances/${id}/split`, { open }).catch(() => {});
30
+ }
31
+
32
+ function clamp(pct: number): number {
33
+ return Math.min(80, Math.max(20, pct));
34
+ }
35
+
36
+ function dragTo(clientX: number) {
37
+ const rect = container.getBoundingClientRect();
38
+ if (rect.width > 0) ratio = clamp(((clientX - rect.left) / rect.width) * 100);
39
+ }
40
+
41
+ function onDividerDown(e: PointerEvent) {
42
+ e.preventDefault(); // or the drag selects terminal text instead
43
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
44
+ }
45
+
46
+ function onDividerMove(e: PointerEvent) {
47
+ if ((e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) dragTo(e.clientX);
48
+ }
49
+
50
+ function onDividerKey(e: KeyboardEvent) {
51
+ if (e.key === 'ArrowLeft') ratio = clamp(ratio - 5);
52
+ else if (e.key === 'ArrowRight') ratio = clamp(ratio + 5);
53
+ else return;
54
+ e.preventDefault();
55
+ }
56
+ </script>
57
+
58
+ {#snippet splitToggle()}
59
+ <button
60
+ type="button"
61
+ onclick={toggle}
62
+ aria-pressed={open}
63
+ title={open ? 'Hide the shell pane' : 'Open a shell alongside Claude'}
64
+ aria-label={open ? 'Hide the shell pane' : 'Open a shell alongside Claude'}
65
+ >
66
+ {#if open}<PanelRightClose size={14} />{:else}<PanelRightOpen size={14} />{/if}
67
+ </button>
68
+ {/snippet}
69
+
70
+ <div class="split" bind:this={container}>
71
+ <!-- Clicking a terminal focuses it anyway; capture that so tab switches restore the same side. -->
72
+ <div
73
+ class="side"
74
+ style:flex-basis={open ? `${ratio}%` : '100%'}
75
+ onpointerdowncapture={() => (focusSide = 'left')}
76
+ >
77
+ <TerminalPane
78
+ {id}
79
+ {active}
80
+ focus={active && focusSide === 'left'}
81
+ actions={open ? undefined : splitToggle}
82
+ />
83
+ </div>
84
+ {#if mounted}
85
+ <!-- A focusable separator is the ARIA window-splitter pattern; the rule doesn't model it. -->
86
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
87
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
88
+ <div
89
+ class="divider"
90
+ class:hidden={!open}
91
+ role="separator"
92
+ aria-orientation="vertical"
93
+ aria-label="Resize terminal panes"
94
+ aria-valuenow={Math.round(ratio)}
95
+ aria-valuemin={20}
96
+ aria-valuemax={80}
97
+ tabindex="0"
98
+ onpointerdown={onDividerDown}
99
+ onpointermove={onDividerMove}
100
+ ondblclick={() => (ratio = 50)}
101
+ onkeydown={onDividerKey}
102
+ ></div>
103
+ <div
104
+ class="side"
105
+ class:hidden={!open}
106
+ style:flex-basis="{100 - ratio}%"
107
+ onpointerdowncapture={() => (focusSide = 'right')}
108
+ >
109
+ <TerminalPane
110
+ {id}
111
+ arg="shell"
112
+ active={active && open}
113
+ focus={active && open && focusSide === 'right'}
114
+ actions={open ? splitToggle : undefined}
115
+ />
116
+ </div>
117
+ {/if}
118
+ </div>
119
+
120
+ <style>
121
+ .split {
122
+ display: flex;
123
+ width: 100%;
124
+ height: 100%;
125
+ }
126
+ /* Positioned so each pane's absolute overlay anchors to its own half, not the whole split. */
127
+ .side {
128
+ position: relative;
129
+ /* No grow: the two flex-basis percentages are the split, the divider eats the rounding. */
130
+ flex: 0 1 auto;
131
+ min-width: 0;
132
+ }
133
+ .side.hidden {
134
+ display: none;
135
+ }
136
+ .divider {
137
+ flex: none;
138
+ width: 7px;
139
+ margin: 0 -3px; /* a wider grab target than the 1px rule it draws */
140
+ cursor: col-resize;
141
+ background: var(--rule);
142
+ background-clip: content-box;
143
+ border-left: 3px solid transparent;
144
+ border-right: 3px solid transparent;
145
+ z-index: 1;
146
+ }
147
+ .divider.hidden {
148
+ display: none;
149
+ }
150
+ .divider:focus-visible {
151
+ outline: 2px solid var(--ink);
152
+ outline-offset: -2px;
153
+ }
154
+ </style>
@@ -21,6 +21,8 @@ export const CHECK_SCRIPT = `ls -d ${EXT_GLOB} >/dev/null 2>&1 && echo 1 || echo
21
21
  export const claudeCodeIdeExtension: Injection = {
22
22
  id: 'claude-code-ide-extension',
23
23
  label: 'Claude Code IDE extension',
24
+ // code-server-only; terminal-mode instances never run the extension host.
25
+ modes: ['ide'],
24
26
 
25
27
  async apply(target, log) {
26
28
  log('Installing Claude Code IDE extension…\n');
@@ -0,0 +1,43 @@
1
+ import { execInContainer } from '../lib/exec.server.ts';
2
+ import type { Injection } from '../lib/injections.server.ts';
3
+
4
+ /**
5
+ * The claude-code devcontainer feature installs `npm install -g @anthropic-ai/claude-code`
6
+ * (always latest) at *build* time, so a cached feature layer keeps serving whatever version
7
+ * was current when the layer was first baked. This re-checks at boot and only reinstalls when
8
+ * behind — no-op if Claude Code isn't installed (project-owned images own their tooling) or
9
+ * npm/registry is unreachable, and it echoes `updated <old> -> <new>` so `apply()` can log it.
10
+ */
11
+ export const UPDATE_SCRIPT =
12
+ 'command -v claude >/dev/null 2>&1 || exit 0; ' +
13
+ 'command -v npm >/dev/null 2>&1 || exit 0; ' +
14
+ "installed=$(claude --version 2>/dev/null | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+' | head -n1); " +
15
+ 'latest=$(npm view @anthropic-ai/claude-code version 2>/dev/null); ' +
16
+ 'if [ -z "$latest" ]; then exit 0; fi; ' +
17
+ 'if [ "$installed" = "$latest" ]; then echo "current $latest"; exit 0; fi; ' +
18
+ 'npm install -g @anthropic-ai/claude-code@latest >/dev/null 2>&1 || exit 1; ' +
19
+ 'echo "updated $installed -> $latest"';
20
+
21
+ /** Best-effort, non-fatal — a failed update leaves the cached version in place. */
22
+ export const claudeCodeUpdate: Injection = {
23
+ id: 'claude-code-update',
24
+ label: 'Claude Code up to date',
25
+
26
+ async apply(target, log) {
27
+ log('Checking Claude Code is up to date…\n');
28
+ // Omitting remoteUser runs as root, which the global npm reinstall needs.
29
+ const res = await execInContainer(
30
+ { containerId: target.containerId },
31
+ { script: UPDATE_SCRIPT, capture: true }
32
+ );
33
+ if (!res.ok) {
34
+ log(`⚠ Claude Code update check failed: ${res.error} — keeping the installed version\n`);
35
+ return;
36
+ }
37
+ const status = res.stdout.split('\n').at(-1)?.trim() ?? '';
38
+ if (status.startsWith('updated ')) log(`✓ Claude Code ${status.slice('updated '.length)}\n`);
39
+ else if (status.startsWith('current '))
40
+ log(`✓ Claude Code already latest (${status.slice('current '.length)})\n`);
41
+ else log('✓ Claude Code version check done\n');
42
+ }
43
+ };
@@ -0,0 +1,49 @@
1
+ import { checkPresence, execInContainer } from '../lib/exec.server.ts';
2
+ import type { Injection } from '../lib/injections.server.ts';
3
+
4
+ /**
5
+ * Package managers first (ttyd is in recent Debian/Ubuntu/Alpine repos), then the upstream
6
+ * static binary for arches/distros without a package. The default image is Debian, so apt-get
7
+ * is the real path.
8
+ */
9
+ export const INSTALL_SCRIPT =
10
+ 'if command -v ttyd >/dev/null 2>&1; then exit 0; fi; ' +
11
+ 'export DEBIAN_FRONTEND=noninteractive; ' +
12
+ 'if command -v apt-get >/dev/null 2>&1; then apt-get update -y >/dev/null 2>&1 && apt-get install -y --no-install-recommends ttyd >/dev/null 2>&1 && command -v ttyd >/dev/null 2>&1 && exit 0; fi; ' +
13
+ 'if command -v apk >/dev/null 2>&1; then apk add --no-cache ttyd >/dev/null 2>&1 && command -v ttyd >/dev/null 2>&1 && exit 0; fi; ' +
14
+ 'case "$(uname -m)" in x86_64|amd64) t=x86_64;; aarch64|arm64) t=aarch64;; armv7l|armv6l) t=arm;; i686|i386) t=i686;; *) echo "unsupported arch: $(uname -m)" >&2; exit 1;; esac; ' +
15
+ 'url="https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.$t"; ' +
16
+ 'if command -v curl >/dev/null 2>&1; then curl -fsSL "$url" -o /usr/local/bin/ttyd; ' +
17
+ 'elif command -v wget >/dev/null 2>&1; then wget -qO /usr/local/bin/ttyd "$url"; ' +
18
+ 'else echo "no curl/wget to fetch ttyd" >&2; exit 1; fi; ' +
19
+ 'chmod +x /usr/local/bin/ttyd; command -v ttyd >/dev/null 2>&1';
20
+
21
+ const CHECK_SCRIPT = 'command -v ttyd >/dev/null 2>&1 && echo 1 || echo 0';
22
+
23
+ /**
24
+ * The runtime fallback to the build-time `codebay-ttyd` feature (the primary install), mirroring
25
+ * tmux. Terminal-mode only — `resolveInjections(mode)` keeps it out of IDE instances entirely.
26
+ */
27
+ export const ttyd: Injection = {
28
+ id: 'ttyd',
29
+ label: 'ttyd',
30
+ modes: ['terminal'],
31
+
32
+ async apply(target, log) {
33
+ log('Installing ttyd…\n');
34
+ // Omitting remoteUser runs as root, which the package install / /usr/local/bin write needs.
35
+ const install = await execInContainer(
36
+ { containerId: target.containerId },
37
+ { script: INSTALL_SCRIPT }
38
+ );
39
+ log(
40
+ install.ok
41
+ ? '✓ ttyd installed\n'
42
+ : `⚠ ttyd install failed: ${install.error} — the terminal won't be reachable\n`
43
+ );
44
+ },
45
+
46
+ async check(target) {
47
+ return checkPresence(target, CHECK_SCRIPT);
48
+ }
49
+ };
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import { basicAuth } 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
6
  import {
7
+ APP_VERSION,
7
8
  BASIC_AUTH_PASSWORD,
8
9
  HOST,
9
10
  PORT,
@@ -66,7 +67,7 @@ await Mochi.serve({
66
67
  });
67
68
 
68
69
  const url = 'http://localhost:' + PORT;
69
- console.log(`Server running at ${url} (bound to ${HOST})`);
70
+ console.log(`codebay v${APP_VERSION} — server running at ${url} (bound to ${HOST})`);
70
71
 
71
72
  if (process.env.DISABLE_OPEN_BROWSER !== '1') {
72
73
  const openCmd =
@@ -59,6 +59,9 @@ export const PORT_MAX = 8999;
59
59
  /** Inside the container, not on the host. */
60
60
  export const CODE_SERVER_PORT = 8080;
61
61
 
62
+ /** ttyd's default listen port, used in terminal mode instead of code-server's. */
63
+ export const TTYD_PORT = 7681;
64
+
62
65
  /** `base:ubuntu` is multi-arch, unlike the `universal` images, which are amd64-only. */
63
66
  export const DEFAULT_IMAGE = 'mcr.microsoft.com/devcontainers/base:ubuntu';
64
67
 
@@ -10,6 +10,9 @@ const MIGRATIONS_DIR = join(import.meta.dir, '../../migrations');
10
10
 
11
11
  export type InstanceStatus = 'creating' | 'running' | 'stopped' | 'error';
12
12
 
13
+ /** `'ide'` serves full code-server; `'terminal'` serves only ttyd + Claude Code. */
14
+ export type InstanceMode = 'ide' | 'terminal';
15
+
13
16
  export interface InstanceRow {
14
17
  id: string;
15
18
  name: string;
@@ -27,6 +30,10 @@ export interface InstanceRow {
27
30
  remote_user: string | null;
28
31
  /** `'local'` when the folder shipped its own config, else the injected image; null pre-dates this. */
29
32
  image_source: string | null;
33
+ /** Fixed at creation: which editor surface this instance provisions and serves. */
34
+ mode: InstanceMode;
35
+ /** Terminal mode only: 1 when the scratch-shell pane was left open, so a reload restores it. */
36
+ terminal_split: number;
30
37
  }
31
38
 
32
39
  export interface PortForwardRow {
@@ -57,8 +64,8 @@ export function closeDb(): void {
57
64
  export function insertInstance(row: InstanceRow): void {
58
65
  db.query(
59
66
  `INSERT INTO instances
60
- (id, name, source_path, workspace_path, host_port, container_id, remote_workspace_folder, status, error, created_at, bridge_token, remote_user, image_source)
61
- VALUES ($id, $name, $source_path, $workspace_path, $host_port, $container_id, $remote_workspace_folder, $status, $error, $created_at, $bridge_token, $remote_user, $image_source)`
67
+ (id, name, source_path, workspace_path, host_port, container_id, remote_workspace_folder, status, error, created_at, bridge_token, remote_user, image_source, mode, terminal_split)
68
+ VALUES ($id, $name, $source_path, $workspace_path, $host_port, $container_id, $remote_workspace_folder, $status, $error, $created_at, $bridge_token, $remote_user, $image_source, $mode, $terminal_split)`
62
69
  ).run({
63
70
  $id: row.id,
64
71
  $name: row.name,
@@ -72,7 +79,9 @@ export function insertInstance(row: InstanceRow): void {
72
79
  $created_at: row.created_at,
73
80
  $bridge_token: row.bridge_token,
74
81
  $remote_user: row.remote_user,
75
- $image_source: row.image_source
82
+ $image_source: row.image_source,
83
+ $mode: row.mode,
84
+ $terminal_split: row.terminal_split
76
85
  });
77
86
  }
78
87
 
@@ -133,7 +142,8 @@ const UPDATABLE_COLUMNS = [
133
142
  'status',
134
143
  'error',
135
144
  'remote_user',
136
- 'image_source'
145
+ 'image_source',
146
+ 'terminal_split'
137
147
  ] as const;
138
148
 
139
149
  type UpdatableColumn = (typeof UPDATABLE_COLUMNS)[number];