codebay 0.8.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 (79) hide show
  1. package/.mochi/import-css/xterm-1yy1hf1g.css +252 -0
  2. package/.mochi/manifest.json +330 -211
  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/migrations/0005_instance_mode.sql +1 -0
  38. package/migrations/0006_terminal_split.sql +1 -0
  39. package/package.json +3 -1
  40. package/src/avatars/serialize.ts +35 -6
  41. package/src/components/AppShell.svelte +46 -8
  42. package/src/components/Avatar.svelte +3 -2
  43. package/src/components/AvatarEditor.svelte +96 -19
  44. package/src/components/AvatarsView.svelte +0 -0
  45. package/src/components/DashboardView.svelte +22 -7
  46. package/src/components/FolderBrowser.svelte +88 -8
  47. package/src/components/HealthBox.svelte +12 -5
  48. package/src/components/IdeBar.svelte +10 -0
  49. package/src/components/InstanceCard.svelte +3 -1
  50. package/src/components/InstanceView.svelte +1 -0
  51. package/src/components/SettingsView.svelte +108 -0
  52. package/src/components/TerminalPane.svelte +274 -0
  53. package/src/components/TerminalSplit.svelte +154 -0
  54. package/src/container-injections/claude-code-ide-extension.ts +2 -0
  55. package/src/container-injections/ttyd.ts +49 -0
  56. package/src/lib/config.server.ts +3 -0
  57. package/src/lib/db.server.ts +14 -4
  58. package/src/lib/devcontainer.server.ts +136 -22
  59. package/src/lib/health.server.ts +1 -1
  60. package/src/lib/injections.server.ts +15 -5
  61. package/src/lib/instances.server.ts +64 -10
  62. package/src/lib/proxy.server.ts +87 -11
  63. package/src/pages/App.svelte +5 -3
  64. package/src/pages/Settings.svelte +4 -0
  65. package/src/routes.ts +55 -6
  66. package/src/types.ts +18 -0
  67. package/.mochi/svelte-client/HydratableIsland-qyjx1d98.js +0 -1
  68. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-fkna7bj2.js +0 -2
  69. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-89wtkb86.js +0 -13
  70. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-eamnbyqk.js +0 -1
  71. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-kdra4mcb.js +0 -28
  72. package/.mochi/svelte-client/chunk-330mrspw.js +0 -2
  73. package/.mochi/svelte-client/chunk-4jgkak13.js +0 -1
  74. package/.mochi/svelte-client/chunk-k5m4kt20.js +0 -2
  75. package/.mochi/svelte-client/chunk-rpdphch7.js +0 -2
  76. package/.mochi/svelte-compile/chunk-bacb4b0r.js +0 -14
  77. package/.mochi/svelte-css/AvatarEditor-2ewwtejaml4e2.css +0 -1
  78. package/.mochi/svelte-css/FolderBrowser-1x428zb3f2kti.css +0 -1
  79. /package/.mochi/svelte-compile/{chunk-btmcxhqy.js → chunk-brjv9pyd.js} +0 -0
@@ -3,15 +3,18 @@ import { existsSync, statSync } from 'node:fs';
3
3
  import { basename, dirname, join, relative } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { INSTALL_SCRIPT as TMUX_INSTALL_SCRIPT } from '../container-injections/tmux.ts';
6
+ import { INSTALL_SCRIPT as TTYD_INSTALL_SCRIPT } from '../container-injections/ttyd.ts';
6
7
  import { EXTENSION_ID as CLAUDE_CODE_EXTENSION_ID } from '../container-injections/claude-code-ide-extension.ts';
7
8
  import {
8
9
  CODE_SERVER_PORT,
9
10
  DEFAULT_IMAGE,
10
11
  PUBLISH_HOST,
12
+ TTYD_PORT,
11
13
  devcontainerBin,
12
14
  dockerEnv
13
15
  } from './config.server.ts';
14
16
  import { spawnCapture } from './spawn.server.ts';
17
+ import type { InstanceMode } from './db.server.ts';
15
18
  import type { PortForward } from '../types.ts';
16
19
 
17
20
  const CODE_SERVER_FEATURE = 'ghcr.io/coder/devcontainer-features/code-server:1';
@@ -49,11 +52,32 @@ const TMUX_FEATURE_INSTALL =
49
52
  ') || echo "codebay-tmux: install failed (non-fatal); the manager retries after the container starts"\n' +
50
53
  'exit 0\n';
51
54
 
55
+ /** Relative to .devcontainer/. Only written for terminal-mode instances. */
56
+ const TTYD_FEATURE_DIR = 'codebay-ttyd';
57
+
58
+ const TTYD_FEATURE_METADATA = {
59
+ id: 'codebay-ttyd',
60
+ version: '1.0.0',
61
+ name: 'ttyd (Codebay, best-effort)',
62
+ description:
63
+ 'Installs ttyd at image build time so the terminal is reachable in a firewalled container. Never fails the build.'
64
+ };
65
+
66
+ /** Best-effort like tmux: build time is the only reliable moment to fetch in an egress-firewalled container. */
67
+ const TTYD_FEATURE_INSTALL =
68
+ '#!/bin/sh\n' +
69
+ '(\n' +
70
+ `${TTYD_INSTALL_SCRIPT}\n` +
71
+ ') || echo "codebay-ttyd: install failed (non-fatal); the manager retries after the container starts"\n' +
72
+ 'exit 0\n';
73
+
52
74
  /** Manager-dropped files the project's own .gitignore won't cover, kept out of git status. */
53
75
  const MANAGER_GIT_EXCLUDES = [
54
76
  '/.devcontainer/code-server-settings.json',
55
77
  '/.devcontainer/devcontainer-lock.json',
56
78
  '/.devcontainer/codebay-tmux/',
79
+ '/.devcontainer/codebay-ttyd/',
80
+ '/.devcontainer/codebay-terminal.sh',
57
81
  '/.vscode/tasks.json'
58
82
  ];
59
83
 
@@ -86,6 +110,9 @@ const CODE_SERVER_SETTINGS = {
86
110
 
87
111
  const TMUX_SESSION = 'codebay';
88
112
 
113
+ /** The split view's right-hand pane: a plain shell, kept apart from the Claude session. */
114
+ const TMUX_SHELL_SESSION = 'codebay-shell';
115
+
89
116
  /** Written to `$HOME` when the post-up injection sequence finishes; the terminal launcher waits on it. */
90
117
  export const INJECTIONS_DONE_FILE = '.codebay-injections-done';
91
118
 
@@ -148,6 +175,52 @@ const CODE_SERVER_LAUNCH =
148
175
  `nohup code-server --bind-addr 0.0.0.0:${CODE_SERVER_PORT} --auth none ` +
149
176
  `--disable-workspace-trust \\"$PWD\\" >/tmp/code-server.log 2>&1 &"`;
150
177
 
178
+ /** Staged next to the config; ttyd runs it as its command so the nested quoting stays in a real file. */
179
+ const TTYD_LAUNCH_SCRIPT_FILE = 'codebay-terminal.sh';
180
+
181
+ /** The one `?arg=` value ttyd is allowed to act on; anything else falls through to Claude. */
182
+ export const TTYD_SHELL_ARG = 'shell';
183
+
184
+ /**
185
+ * ttyd runs this per browser connection. `tmux -A` attaches to the shared `codebay` session
186
+ * (or creates it), so Claude survives reconnects. No WAIT_FOR_IDE_BRIDGE — terminal mode has
187
+ * no code-server extension host to race. Kept in a file so the tmux command's own quoting isn't
188
+ * nested inside the postStartCommand's shell quoting.
189
+ *
190
+ * `$1` comes from the URL's `?arg=` (ttyd's --url-arg), so it is untrusted — hence a literal
191
+ * compare and never an eval. The shell branch sits above the injections wait so the split view's
192
+ * scratch shell opens immediately instead of blocking on Claude's boot sentinel.
193
+ */
194
+ const TTYD_LAUNCH_SCRIPT =
195
+ '#!/usr/bin/env bash\n' +
196
+ 'export SHELL="${SHELL:-/bin/bash}"\n' +
197
+ `if [ "$1" = "${TTYD_SHELL_ARG}" ]; then\n` +
198
+ ' if command -v tmux >/dev/null 2>&1; then\n' +
199
+ ` exec tmux new-session -A -s ${TMUX_SHELL_SESSION}\n` +
200
+ ' fi\n' +
201
+ ' exec "$SHELL" -l\n' +
202
+ 'fi\n' +
203
+ WAIT_FOR_INJECTIONS +
204
+ '\n' +
205
+ `if command -v tmux >/dev/null 2>&1; then\n` +
206
+ ` exec tmux new-session -A -s ${TMUX_SESSION} 'claude --dangerously-skip-permissions; exec "$SHELL" -l'\n` +
207
+ 'fi\n' +
208
+ 'claude --dangerously-skip-permissions\n' +
209
+ 'exec "$SHELL" -l\n';
210
+
211
+ // ttyd defaults to read-only, so --writable is required for keyboard input. Guarded by `pgrep -x`
212
+ // (process name) so a folderOpen/rebuild can't stack a second daemon — never `pgrep -f`, whose
213
+ // cmdline match also matches this very shell, which would skip the launch every time.
214
+ const TTYD_LAUNCH =
215
+ `bash -c "` +
216
+ `export SHELL=\\"\${SHELL:-/bin/bash}\\"; ` +
217
+ `command -v ttyd >/dev/null 2>&1 || exit 0; ` +
218
+ `pgrep -x ttyd >/dev/null 2>&1 || ` +
219
+ // --url-arg forwards the connection URL's `?arg=` into the launcher's argv, so one ttyd serves
220
+ // both the Claude session and the split view's scratch shell (--writable is covered above).
221
+ `nohup ttyd --port ${TTYD_PORT} --writable --url-arg ` +
222
+ `bash \\"$PWD/.devcontainer/${TTYD_LAUNCH_SCRIPT_FILE}\\" >/tmp/ttyd.log 2>&1 &"`;
223
+
151
224
  export async function devcontainerCliAvailable(): Promise<boolean> {
152
225
  return (await spawnCapture([devcontainerBin(), '--version'])) !== null;
153
226
  }
@@ -288,7 +361,15 @@ export async function readDeclaredContainerPorts(workspaceDir: string): Promise<
288
361
  const ports = new Set<number>();
289
362
  for (const entry of entries) {
290
363
  const port = containerPortOf(entry);
291
- if (Number.isInteger(port) && port > 0 && port <= 65535 && port !== CODE_SERVER_PORT) {
364
+ // Both reserved internal ports are excluded regardless of mode, so neither can be
365
+ // double-mapped when writeOverrideConfig renders its own appPort entry for it.
366
+ if (
367
+ Number.isInteger(port) &&
368
+ port > 0 &&
369
+ port <= 65535 &&
370
+ port !== CODE_SERVER_PORT &&
371
+ port !== TTYD_PORT
372
+ ) {
292
373
  ports.add(port);
293
374
  }
294
375
  }
@@ -303,8 +384,10 @@ export async function writeOverrideConfig(
303
384
  workspaceDir: string,
304
385
  hostPort: number,
305
386
  forwards: PortForward[] = [],
306
- defaultImage: string = DEFAULT_IMAGE
387
+ defaultImage: string = DEFAULT_IMAGE,
388
+ mode: InstanceMode = 'ide'
307
389
  ): Promise<{ imageSource: string }> {
390
+ const isTerminal = mode === 'terminal';
308
391
  const target = configPath(workspaceDir);
309
392
  let config: DevcontainerConfig = {};
310
393
 
@@ -326,23 +409,30 @@ export async function writeOverrideConfig(
326
409
  }
327
410
 
328
411
  // A local feature is addressed relative to the config, which sits at one of two depths.
329
- const tmuxFeatureKey = `./${relative(dirname(target), join(workspaceDir, '.devcontainer', TMUX_FEATURE_DIR))}`;
412
+ const featureKey = (dir: string) =>
413
+ `./${relative(dirname(target), join(workspaceDir, '.devcontainer', dir))}`;
414
+ const tmuxFeatureKey = featureKey(TMUX_FEATURE_DIR);
415
+ const ttydFeatureKey = featureKey(TTYD_FEATURE_DIR);
330
416
 
331
- // Node/Claude/gh only for the default image — projects with their own config own their tooling.
417
+ // Terminal mode swaps code-server for ttyd; otherwise (tmux, tooling) the two are identical.
418
+ // Node/Claude/gh: always in terminal mode (its launcher *is* `claude`), else only the default image.
332
419
  config.features = {
333
420
  ...(config.features ?? {}),
334
- [CODE_SERVER_FEATURE]: { host: '0.0.0.0', port: CODE_SERVER_PORT, auth: 'none' },
421
+ ...(isTerminal
422
+ ? { [ttydFeatureKey]: {} }
423
+ : { [CODE_SERVER_FEATURE]: { host: '0.0.0.0', port: CODE_SERVER_PORT, auth: 'none' } }),
335
424
  [tmuxFeatureKey]: {},
336
- ...(hadConfig
337
- ? {}
338
- : { [NODE_FEATURE]: {}, [CLAUDE_CODE_FEATURE]: {}, [GITHUB_CLI_FEATURE]: {} })
425
+ ...(isTerminal || !hadConfig
426
+ ? { [NODE_FEATURE]: {}, [CLAUDE_CODE_FEATURE]: {}, [GITHUB_CLI_FEATURE]: {} }
427
+ : {})
339
428
  };
340
429
 
430
+ const servedPort = isTerminal ? TTYD_PORT : CODE_SERVER_PORT;
341
431
  // Rendered from scratch rather than merged, so removing a forward actually drops its mapping.
342
- // code-server stays loopback even under HOST=0.0.0.0 — it has no auth of its own, so the LAN
343
- // reaches it only through the Basic-Auth-gated /p/:id/ proxy.
432
+ // The served port stays loopback even under HOST=0.0.0.0 — neither code-server nor ttyd has
433
+ // auth of its own, so the LAN reaches it only through the Basic-Auth-gated /p/:id/ proxy.
344
434
  config.appPort = [
345
- `127.0.0.1:${hostPort}:${CODE_SERVER_PORT}`,
435
+ `127.0.0.1:${hostPort}:${servedPort}`,
346
436
  ...forwards.map((f) => `${PUBLISH_HOST}:${f.host_port}:${f.container_port}`)
347
437
  ];
348
438
 
@@ -351,26 +441,30 @@ export async function writeOverrideConfig(
351
441
  runArgs.add(HOST_GATEWAY_ARG);
352
442
  config.runArgs = [...runArgs];
353
443
 
444
+ const launch = isTerminal ? TTYD_LAUNCH : CODE_SERVER_LAUNCH;
354
445
  const existing = config.postStartCommand;
355
446
  config.postStartCommand =
356
- typeof existing === 'string' && existing.trim()
357
- ? `${existing} && ${CODE_SERVER_LAUNCH}`
358
- : CODE_SERVER_LAUNCH;
447
+ typeof existing === 'string' && existing.trim() ? `${existing} && ${launch}` : launch;
359
448
 
360
449
  await mkdir(join(workspaceDir, '.devcontainer'), { recursive: true }).catch(() => {});
361
450
  await writeFile(target, JSON.stringify(config, null, 2) + '\n', 'utf8');
362
451
 
363
- // Staged next to the config; CODE_SERVER_LAUNCH copies it into the user-data dir on first start.
364
- await writeFile(
365
- join(workspaceDir, '.devcontainer', CODE_SERVER_SETTINGS_FILE),
366
- JSON.stringify(CODE_SERVER_SETTINGS, null, 2) + '\n',
367
- 'utf8'
368
- );
452
+ if (isTerminal) {
453
+ await writeTtydFeature(workspaceDir);
454
+ await writeTerminalLaunchScript(workspaceDir);
455
+ } else {
456
+ // Staged next to the config; CODE_SERVER_LAUNCH copies it into the user-data dir on first start.
457
+ await writeFile(
458
+ join(workspaceDir, '.devcontainer', CODE_SERVER_SETTINGS_FILE),
459
+ JSON.stringify(CODE_SERVER_SETTINGS, null, 2) + '\n',
460
+ 'utf8'
461
+ );
462
+ // The VS Code Terminal task is code-server-only; ttyd runs its own launcher script instead.
463
+ await writeTerminalTask(workspaceDir);
464
+ }
369
465
 
370
466
  await writeTmuxFeature(workspaceDir);
371
467
 
372
- await writeTerminalTask(workspaceDir);
373
-
374
468
  await writeLocalGitExclude(workspaceDir);
375
469
 
376
470
  return { imageSource };
@@ -460,6 +554,26 @@ async function writeTmuxFeature(workspaceDir: string): Promise<void> {
460
554
  await chmod(installPath, 0o755);
461
555
  }
462
556
 
557
+ async function writeTtydFeature(workspaceDir: string): Promise<void> {
558
+ const dir = join(workspaceDir, '.devcontainer', TTYD_FEATURE_DIR);
559
+ await mkdir(dir, { recursive: true }).catch(() => {});
560
+ await writeFile(
561
+ join(dir, 'devcontainer-feature.json'),
562
+ JSON.stringify(TTYD_FEATURE_METADATA, null, 2) + '\n',
563
+ 'utf8'
564
+ );
565
+ const installPath = join(dir, 'install.sh');
566
+ await writeFile(installPath, TTYD_FEATURE_INSTALL, 'utf8');
567
+ await chmod(installPath, 0o755);
568
+ }
569
+
570
+ /** Staged next to the config; TTYD_LAUNCH runs it as ttyd's command. */
571
+ async function writeTerminalLaunchScript(workspaceDir: string): Promise<void> {
572
+ const path = join(workspaceDir, '.devcontainer', TTYD_LAUNCH_SCRIPT_FILE);
573
+ await writeFile(path, TTYD_LAUNCH_SCRIPT, 'utf8');
574
+ await chmod(path, 0o755);
575
+ }
576
+
463
577
  /** Replaces the managed task rather than skipping it, so a rebuild picks up command changes. */
464
578
  async function writeTerminalTask(workspaceDir: string): Promise<void> {
465
579
  const tasksPath = join(workspaceDir, '.vscode', 'tasks.json');
@@ -41,7 +41,7 @@ async function check(row: InstanceRow): Promise<InstanceHealth> {
41
41
  codeServerAccessible(row.host_port),
42
42
  publishedContainerPorts(row.container_id),
43
43
  Promise.all(
44
- resolveInjections()
44
+ resolveInjections(row.mode)
45
45
  .filter((i) => i.check)
46
46
  .map(async (i) => ({
47
47
  id: i.id,
@@ -1,8 +1,9 @@
1
- import type { InstanceRow } from './db.server.ts';
1
+ import type { InstanceMode, InstanceRow } from './db.server.ts';
2
2
  import type { ExecTarget } from './exec.server.ts';
3
3
  import { getOption } from './db.server.ts';
4
4
  import { gitSafeDirectory } from '../container-injections/git-safe-directory.ts';
5
5
  import { tmux } from '../container-injections/tmux.ts';
6
+ import { ttyd } from '../container-injections/ttyd.ts';
6
7
  import { gitIdentity } from '../container-injections/git-identity.ts';
7
8
  import { claudeCodeCredentials } from '../container-injections/claude-code-credentials.ts';
8
9
  import { claudeCodeCustom } from '../container-injections/claude-code-custom.ts';
@@ -32,6 +33,8 @@ export interface Injection {
32
33
  /** Doubles as the health-row key. */
33
34
  id: string;
34
35
  label: string;
36
+ /** Restrict to specific instance modes; absent means it applies to every mode. */
37
+ modes?: InstanceMode[];
35
38
  /** Omit for injections with no host dependency — presence is what draws the setup-UI chip. */
36
39
  auth?: {
37
40
  /** Short instruction shown when unavailable, e.g. "run `gh auth login`". */
@@ -49,6 +52,8 @@ const BASE_INJECTIONS_HEAD: Injection[] = [
49
52
  gitSafeDirectory,
50
53
  // The package install is the slowest injection, so start it before a folderOpen can find tmux missing.
51
54
  tmux,
55
+ // Terminal-mode only (filtered by resolveInjections); a download, so run it early like tmux.
56
+ ttyd,
52
57
  gitIdentity
53
58
  ];
54
59
 
@@ -69,14 +74,19 @@ const BASE_INJECTIONS_TAIL: Injection[] = [
69
74
  hostEnvVars
70
75
  ];
71
76
 
72
- /** Resolved per call so toggling the custom-endpoint setting takes effect without a restart. */
73
- export function resolveInjections(): Injection[] {
77
+ /**
78
+ * Resolved per call so toggling the custom-endpoint setting takes effect without a restart.
79
+ * `mode` drops injections that don't apply to it (e.g. ttyd off IDE instances, the IDE
80
+ * extension off terminal ones); omit it in mode-agnostic contexts to keep every injection.
81
+ */
82
+ export function resolveInjections(mode?: InstanceMode): Injection[] {
74
83
  const claudeInjection =
75
84
  getOption('custom_endpoint_enabled') === '1' ? claudeCodeCustom : claudeCodeCredentials;
76
- return [...BASE_INJECTIONS_HEAD, claudeInjection, ...BASE_INJECTIONS_TAIL];
85
+ const all = [...BASE_INJECTIONS_HEAD, claudeInjection, ...BASE_INJECTIONS_TAIL];
86
+ return mode ? all.filter((i) => !i.modes || i.modes.includes(mode)) : all;
77
87
  }
78
88
 
79
- /** @deprecated Use `resolveInjections()`; this ignores settings and is kept for the tests. */
89
+ /** @deprecated Use `resolveInjections()`; this ignores settings/mode and is kept for the tests. */
80
90
  export const injections: Injection[] = [
81
91
  ...BASE_INJECTIONS_HEAD,
82
92
  claudeCodeCredentials,
@@ -6,7 +6,8 @@ import {
6
6
  DEFAULT_COPY_IGNORE,
7
7
  DEFAULT_IMAGE,
8
8
  INSTANCES_DIR,
9
- parseCopyIgnore
9
+ parseCopyIgnore,
10
+ TTYD_PORT
10
11
  } from './config.server.ts';
11
12
  import {
12
13
  allForwards,
@@ -23,6 +24,7 @@ import {
23
24
  recordFolder,
24
25
  updateInstance,
25
26
  usedPorts,
27
+ type InstanceMode,
26
28
  type InstanceRow,
27
29
  type InstanceStatus
28
30
  } from './db.server.ts';
@@ -51,7 +53,13 @@ import { isRepoUrl, parseRepoUrl } from './repo-url.ts';
51
53
  import { currentHealthSnapshots, stopHealthMonitor, syncHealthMonitors } from './health.server.ts';
52
54
  import { pickFreePort } from './ports.server.ts';
53
55
  import type { ServerWebSocket } from 'bun';
54
- import type { Instance, InstanceHealth } from '../types.ts';
56
+ import {
57
+ isInstanceFilter,
58
+ normalizeMode,
59
+ type Instance,
60
+ type InstanceFilter,
61
+ type InstanceHealth
62
+ } from '../types.ts';
55
63
 
56
64
  /**
57
65
  * `bridge_token` authenticates the no-Basic-Auth `/api/bridge/` endpoint, so it
@@ -108,7 +116,11 @@ export type StreamEvent =
108
116
  | { type: 'health'; data: { id: string; health: InstanceHealth } }
109
117
  | { type: 'preflight'; data: { docker: boolean; cli: boolean } }
110
118
  // `name` is the chosen sprite, or null for the default box logo. Lets the header swap live.
111
- | { type: 'pet'; data: { name: string | null } };
119
+ | { type: 'pet'; data: { name: string | null } }
120
+ // The dashboard run-state filter, so a change in one tab propagates to every open client.
121
+ | { type: 'filter'; data: { value: InstanceFilter } }
122
+ // The global default editor surface, so the picker's toggle follows a settings change.
123
+ | { type: 'default-mode'; data: { mode: InstanceMode } };
112
124
 
113
125
  interface StreamHub {
114
126
  sockets: Set<ServerWebSocket<unknown>>;
@@ -159,6 +171,16 @@ export function broadcastPet(name: string | null): void {
159
171
  broadcast({ type: 'pet', data: { name } });
160
172
  }
161
173
 
174
+ /** Called when the filter changes so every open dashboard reflects it without a reload. */
175
+ export function broadcastFilter(value: InstanceFilter): void {
176
+ broadcast({ type: 'filter', data: { value } });
177
+ }
178
+
179
+ /** Settings opens in its own popup, so the dashboard behind it needs the new default pushed. */
180
+ export function broadcastDefaultMode(mode: InstanceMode): void {
181
+ broadcast({ type: 'default-mode', data: { mode } });
182
+ }
183
+
162
184
  async function reconcileInstances(force = false): Promise<void> {
163
185
  const list = await listInstances();
164
186
  const listJson = JSON.stringify(list);
@@ -200,6 +222,14 @@ export function streamOpen(ws: ServerWebSocket<unknown>): void {
200
222
  void backgroundPreflight().then((pf) => sendTo(ws, { type: 'preflight', data: pf }));
201
223
  // Keeps a reconnecting client correct even if the pet changed while it was away.
202
224
  sendTo(ws, { type: 'pet', data: { name: getOption('pet') || null } });
225
+ // Same, for the filter, so a client that reconnects picks up a change made while it was away.
226
+ const savedFilter = getOption('instance_filter');
227
+ sendTo(ws, {
228
+ type: 'filter',
229
+ data: { value: isInstanceFilter(savedFilter) ? savedFilter : 'all' }
230
+ });
231
+ // Same, for the default mode the picker's toggle seeds from.
232
+ sendTo(ws, { type: 'default-mode', data: { mode: getDefaultMode() } });
203
233
  }
204
234
 
205
235
  export function streamClose(ws: ServerWebSocket<unknown>): void {
@@ -291,13 +321,15 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
291
321
  container_port: f.container_port,
292
322
  host_port: f.host_port
293
323
  }));
294
- appendLog(row.id, `Injecting code-server (host port ${row.host_port})\n`);
324
+ const surface = row.mode === 'terminal' ? 'ttyd terminal' : 'code-server';
325
+ appendLog(row.id, `Injecting ${surface} (host port ${row.host_port})\n`);
295
326
  const defaultImage = getOption('default_image') ?? DEFAULT_IMAGE;
296
327
  const { imageSource } = await writeOverrideConfig(
297
328
  row.workspace_path,
298
329
  row.host_port,
299
330
  forwards,
300
- defaultImage
331
+ defaultImage,
332
+ row.mode
301
333
  );
302
334
  updateInstance(row.id, { image_source: imageSource });
303
335
 
@@ -336,7 +368,7 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
336
368
  instance: row
337
369
  };
338
370
  // Swallow per-injection failures so one bad injection can't abort the rest of provisioning.
339
- for (const injection of resolveInjections()) {
371
+ for (const injection of resolveInjections(row.mode)) {
340
372
  try {
341
373
  await injection.apply(target, (msg) => appendLog(row.id, msg));
342
374
  } catch (err) {
@@ -370,11 +402,16 @@ function uniqueName(desired: string, excludeId?: string): string {
370
402
  }
371
403
  }
372
404
 
405
+ /** The global fallback the picker's mode toggle starts on; a per-instance choice overrides it. */
406
+ export function getDefaultMode(): InstanceMode {
407
+ return normalizeMode(getOption('default_mode'));
408
+ }
409
+
373
410
  /** `source` is either a local folder (copied) or a Git URL (cloned); boot runs in the background. */
374
411
  export async function createInstance(
375
412
  source: string,
376
413
  name?: string,
377
- opts: { branch?: string } = {}
414
+ opts: { branch?: string; mode?: InstanceMode } = {}
378
415
  ): Promise<InstanceRow> {
379
416
  const parsedRepo = parseRepoUrl(source);
380
417
  if (parsedRepo) {
@@ -399,7 +436,9 @@ export async function createInstance(
399
436
  created_at: Date.now(),
400
437
  bridge_token: crypto.randomUUID().replace(/-/g, ''),
401
438
  remote_user: null,
402
- image_source: null
439
+ image_source: null,
440
+ mode: opts.mode ?? getDefaultMode(),
441
+ terminal_split: 0
403
442
  };
404
443
  insertInstance(row);
405
444
  // Strip the de-dup `#2` suffix so the recent-folders list keeps the base name.
@@ -455,6 +494,18 @@ export async function listInstances(): Promise<Instance[]> {
455
494
  }));
456
495
  }
457
496
 
497
+ /**
498
+ * Remembers whether the terminal's scratch-shell pane was left open. Deliberately skips
499
+ * `triggerReconcile()` — it's a per-instance UI preference read once at page load, so pushing the
500
+ * whole instance list to every socket on each toggle would be pure noise.
501
+ */
502
+ export function setTerminalSplit(id: string, open: boolean): InstanceRow {
503
+ const row = getInstance(id);
504
+ if (!row) throw new Error('Instance not found');
505
+ updateInstance(id, { terminal_split: open ? 1 : 0 });
506
+ return getInstance(id)!;
507
+ }
508
+
458
509
  export function renameInstance(id: string, name: string): InstanceRow {
459
510
  const row = getInstance(id);
460
511
  if (!row) throw new Error('Instance not found');
@@ -473,8 +524,11 @@ export async function addForwardedPort(id: string, containerPort: number): Promi
473
524
  if (!Number.isInteger(containerPort) || containerPort < 1 || containerPort > 65535) {
474
525
  throw new Error('Port must be an integer between 1 and 65535');
475
526
  }
476
- if (containerPort === CODE_SERVER_PORT) {
477
- throw new Error(`Port ${CODE_SERVER_PORT} is reserved for code-server`);
527
+ const reserved = row.mode === 'terminal' ? TTYD_PORT : CODE_SERVER_PORT;
528
+ if (containerPort === reserved) {
529
+ throw new Error(
530
+ `Port ${reserved} is reserved for ${row.mode === 'terminal' ? 'the terminal' : 'code-server'}`
531
+ );
478
532
  }
479
533
  if (listForwards(id).some((f) => f.container_port === containerPort)) {
480
534
  throw new Error(`Port ${containerPort} is already forwarded`);
@@ -50,6 +50,25 @@ interface RelayState {
50
50
  client?: WebSocket;
51
51
  /** Frames received from the browser before the upstream socket is open. */
52
52
  pending: Frame[];
53
+ /** Frames the upstream sent before the browser socket existed (connect-first path only). */
54
+ inbound?: Frame[];
55
+ /** The client's requested subprotocols, forwarded upstream — ttyd requires its `tty` one. */
56
+ subprotocols?: string[];
57
+ }
58
+
59
+ /** Opens the upstream socket and buffers anything it emits until the browser socket is wired up. */
60
+ function connectUpstream(
61
+ url: string,
62
+ subprotocols: string[]
63
+ ): Promise<{ client: WebSocket; inbound: Frame[] }> {
64
+ return new Promise((resolve, reject) => {
65
+ const client = new WebSocket(url, subprotocols);
66
+ client.binaryType = 'arraybuffer';
67
+ const inbound: Frame[] = [];
68
+ client.onmessage = (e) => inbound.push(e.data as Frame);
69
+ client.onopen = () => resolve({ client, inbound });
70
+ client.onerror = () => reject(new Error('upstream websocket error'));
71
+ });
53
72
  }
54
73
 
55
74
  async function proxyHttp(event: MochiApiEvent, port: number, rest: string): Promise<Response> {
@@ -91,20 +110,53 @@ async function proxyHttp(event: MochiApiEvent, port: number, rest: string): Prom
91
110
  });
92
111
  }
93
112
 
94
- function proxyUpgrade(event: MochiApiEvent, port: number, rest: string): Response {
113
+ async function proxyUpgrade(event: MochiApiEvent, port: number, rest: string): Promise<Response> {
114
+ const requested = event.request.headers.get('sec-websocket-protocol');
115
+ const subprotocols = requested
116
+ ? requested
117
+ .split(',')
118
+ .map((s) => s.trim())
119
+ .filter(Boolean)
120
+ : undefined;
121
+ const upstreamWsUrl = `ws://127.0.0.1:${port}${rest}${event.url.search}`;
122
+
123
+ const state: RelayState = { upstreamWsUrl, pending: [], subprotocols };
124
+ let selected: string | undefined;
125
+
126
+ // When subprotocols are in play, open the upstream first so the 101 echoes the one it actually
127
+ // negotiated instead of guessing its first offer — a mismatch would leave the browser and
128
+ // upstream disagreeing on the protocol. code-server offers none and connects lazily in open().
129
+ if (subprotocols) {
130
+ try {
131
+ const { client, inbound } = await connectUpstream(upstreamWsUrl, subprotocols);
132
+ state.client = client;
133
+ state.inbound = inbound;
134
+ selected = client.protocol || undefined;
135
+ } catch {
136
+ return new Response('Upstream WebSocket unreachable', { status: 502 });
137
+ }
138
+ }
139
+
95
140
  const data: MochiWsData<RelayState> = {
96
141
  __mochiRoutePattern: PROXY_WS_PATTERN,
97
142
  __mochiOpenedAt: performance.now(),
98
143
  __mochiPath: event.url.pathname,
99
- user: { upstreamWsUrl: `ws://127.0.0.1:${port}${rest}${event.url.search}`, pending: [] }
144
+ user: state
100
145
  };
146
+ const headers = selected ? { 'Sec-WebSocket-Protocol': selected } : undefined;
101
147
  // Bun's Server is typed with a fixed WebSocketData; Mochi casts the same way for its own upgrades.
102
148
  const ok = (
103
149
  event.server as unknown as {
104
- upgrade: (req: Request, opts: { data: MochiWsData<RelayState> }) => boolean;
150
+ upgrade: (
151
+ req: Request,
152
+ opts: { data: MochiWsData<RelayState>; headers?: Record<string, string> }
153
+ ) => boolean;
105
154
  }
106
- ).upgrade(event.request, { data });
107
- if (!ok) return new Response('WebSocket upgrade failed', { status: 426 });
155
+ ).upgrade(event.request, { data, headers });
156
+ if (!ok) {
157
+ if (state.client) safeClose(state.client, 1000, ''); // don't orphan the pre-opened upstream
158
+ return new Response('WebSocket upgrade failed', { status: 426 });
159
+ }
108
160
  // Bun already sent the real 101; this is ignored, but Mochi's resolve chain reads a status.
109
161
  return new Response(null, { status: 101 });
110
162
  }
@@ -127,7 +179,7 @@ export const proxyRoutes: Record<string, MochiRouteValue> = {
127
179
 
128
180
  // An upgrade is a GET carrying `Upgrade: websocket`, so Bun routes it here too.
129
181
  if (event.request.headers.get('upgrade')?.toLowerCase() === 'websocket') {
130
- return proxyUpgrade(event, port, rest);
182
+ return await proxyUpgrade(event, port, rest);
131
183
  }
132
184
  return proxyHttp(event, port, rest);
133
185
  }),
@@ -137,14 +189,27 @@ export const proxyRoutes: Record<string, MochiRouteValue> = {
137
189
  upgrade: () => false,
138
190
  open(ws: ServerWebSocket<MochiWsData<RelayState>>) {
139
191
  const state = ws.data.user;
140
- const client = new WebSocket(state.upstreamWsUrl);
192
+ // Pre-opened during the handshake when a subprotocol was negotiated; otherwise connect now.
193
+ const client =
194
+ state.client ??
195
+ (state.subprotocols
196
+ ? new WebSocket(state.upstreamWsUrl, state.subprotocols)
197
+ : new WebSocket(state.upstreamWsUrl));
141
198
  client.binaryType = 'arraybuffer';
142
199
  state.client = client;
143
200
 
144
- client.onopen = () => {
145
- for (const frame of state.pending) sendFrame(client, frame);
146
- state.pending = [];
147
- };
201
+ // Drain whatever the upstream sent before this socket existed (connect-first path only).
202
+ if (state.inbound) {
203
+ for (const frame of state.inbound) {
204
+ try {
205
+ ws.send(frame as never);
206
+ } catch {
207
+ /* browser socket closed */
208
+ }
209
+ }
210
+ state.inbound = undefined;
211
+ }
212
+
148
213
  client.onmessage = (e) => {
149
214
  try {
150
215
  ws.send(e.data);
@@ -154,6 +219,17 @@ export const proxyRoutes: Record<string, MochiRouteValue> = {
154
219
  };
155
220
  client.onclose = (e) => safeClose(ws, e.code, e.reason);
156
221
  client.onerror = () => safeClose(ws, 1011, 'upstream error');
222
+
223
+ // A pre-opened socket already fired `onopen`, so flush now; a lazy one flushes on open.
224
+ if (client.readyState === WebSocket.OPEN) {
225
+ for (const frame of state.pending) sendFrame(client, frame);
226
+ state.pending = [];
227
+ } else {
228
+ client.onopen = () => {
229
+ for (const frame of state.pending) sendFrame(client, frame);
230
+ state.pending = [];
231
+ };
232
+ }
157
233
  },
158
234
  message(ws: ServerWebSocket<MochiWsData<RelayState>>, message) {
159
235
  const state = ws.data.user;
@@ -2,20 +2,22 @@
2
2
  import '@fontsource-variable/doto';
3
3
  import '@fontsource-variable/jetbrains-mono';
4
4
  import AppShell from '../components/AppShell.svelte';
5
- import type { Instance, Preflight } from '../types.ts';
5
+ import type { Instance, InstanceFilter, Preflight } from '../types.ts';
6
6
  import type { AvatarArt } from '../avatars/index.ts';
7
7
 
8
8
  let {
9
9
  preflight,
10
10
  initialPath,
11
11
  snapshot,
12
- pet
12
+ pet,
13
+ filter
13
14
  }: {
14
15
  preflight: Preflight;
15
16
  initialPath: string;
16
17
  snapshot: Instance[];
17
18
  pet?: AvatarArt;
19
+ filter: InstanceFilter;
18
20
  } = $props();
19
21
  </script>
20
22
 
21
- <AppShell {preflight} {initialPath} {snapshot} {pet} mochi:hydrate />
23
+ <AppShell {preflight} {initialPath} {snapshot} {pet} {filter} mochi:hydrate />
@@ -3,9 +3,11 @@
3
3
  import '@fontsource-variable/jetbrains-mono';
4
4
  import SettingsView from '../components/SettingsView.svelte';
5
5
  import type { AvatarArt } from '../avatars/index.ts';
6
+ import type { InstanceMode } from '../types.ts';
6
7
 
7
8
  let {
8
9
  pet,
10
+ defaultMode,
9
11
  defaultImage,
10
12
  builtinImage,
11
13
  disableBuildCache,
@@ -38,6 +40,7 @@
38
40
  version
39
41
  }: {
40
42
  pet?: AvatarArt;
43
+ defaultMode: InstanceMode;
41
44
  defaultImage: string;
42
45
  builtinImage: string;
43
46
  disableBuildCache: boolean;
@@ -73,6 +76,7 @@
73
76
 
74
77
  <SettingsView
75
78
  {pet}
79
+ {defaultMode}
76
80
  {defaultImage}
77
81
  {builtinImage}
78
82
  {disableBuildCache}