codebay 0.8.0 → 0.9.1

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 (80) hide show
  1. package/.mochi/import-css/xterm-1yy1hf1g.css +252 -0
  2. package/.mochi/manifest.json +325 -206
  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/claude-code-install.ts +67 -0
  56. package/src/container-injections/ttyd.ts +49 -0
  57. package/src/lib/config.server.ts +3 -0
  58. package/src/lib/db.server.ts +14 -4
  59. package/src/lib/devcontainer.server.ts +185 -23
  60. package/src/lib/health.server.ts +1 -1
  61. package/src/lib/injections.server.ts +18 -5
  62. package/src/lib/instances.server.ts +68 -10
  63. package/src/lib/proxy.server.ts +26 -5
  64. package/src/pages/App.svelte +5 -3
  65. package/src/pages/Settings.svelte +4 -0
  66. package/src/routes.ts +55 -6
  67. package/src/types.ts +18 -0
  68. package/.mochi/svelte-client/HydratableIsland-qyjx1d98.js +0 -1
  69. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-fkna7bj2.js +0 -2
  70. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-89wtkb86.js +0 -13
  71. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-eamnbyqk.js +0 -1
  72. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-kdra4mcb.js +0 -28
  73. package/.mochi/svelte-client/chunk-330mrspw.js +0 -2
  74. package/.mochi/svelte-client/chunk-4jgkak13.js +0 -1
  75. package/.mochi/svelte-client/chunk-k5m4kt20.js +0 -2
  76. package/.mochi/svelte-client/chunk-rpdphch7.js +0 -2
  77. package/.mochi/svelte-compile/chunk-bacb4b0r.js +0 -14
  78. package/.mochi/svelte-css/AvatarEditor-2ewwtejaml4e2.css +0 -1
  79. package/.mochi/svelte-css/FolderBrowser-1x428zb3f2kti.css +0 -1
  80. /package/.mochi/svelte-compile/{chunk-btmcxhqy.js → chunk-brjv9pyd.js} +0 -0
@@ -3,20 +3,28 @@ 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';
7
+ import { INSTALL_SCRIPT as CLAUDE_INSTALL_SCRIPT } from '../container-injections/claude-code-install.ts';
6
8
  import { EXTENSION_ID as CLAUDE_CODE_EXTENSION_ID } from '../container-injections/claude-code-ide-extension.ts';
7
9
  import {
8
10
  CODE_SERVER_PORT,
9
11
  DEFAULT_IMAGE,
10
12
  PUBLISH_HOST,
13
+ TTYD_PORT,
11
14
  devcontainerBin,
12
15
  dockerEnv
13
16
  } from './config.server.ts';
14
17
  import { spawnCapture } from './spawn.server.ts';
18
+ import type { InstanceMode } from './db.server.ts';
15
19
  import type { PortForward } from '../types.ts';
16
20
 
17
21
  const CODE_SERVER_FEATURE = 'ghcr.io/coder/devcontainer-features/code-server:1';
18
22
 
19
- /** Installs the Claude Code CLI into the default image. Needs Node — supplied by NODE_FEATURE. */
23
+ /**
24
+ * Installs the Claude Code CLI into the default image. Needs Node — supplied by NODE_FEATURE.
25
+ * Default-image only: NODE_FEATURE installs via nvm, which refuses to run when the image sets
26
+ * NPM_CONFIG_PREFIX, so a project-supplied image gets the sniffing CLAUDE_FEATURE_DIR instead.
27
+ */
20
28
  const CLAUDE_CODE_FEATURE = 'ghcr.io/anthropics/devcontainer-features/claude-code:1.0';
21
29
 
22
30
  /** Node.js — required by the Claude Code feature, which no longer bundles it. */
@@ -49,11 +57,52 @@ const TMUX_FEATURE_INSTALL =
49
57
  ') || echo "codebay-tmux: install failed (non-fatal); the manager retries after the container starts"\n' +
50
58
  'exit 0\n';
51
59
 
60
+ /** Relative to .devcontainer/. Only written for terminal-mode instances. */
61
+ const TTYD_FEATURE_DIR = 'codebay-ttyd';
62
+
63
+ const TTYD_FEATURE_METADATA = {
64
+ id: 'codebay-ttyd',
65
+ version: '1.0.0',
66
+ name: 'ttyd (Codebay, best-effort)',
67
+ description:
68
+ 'Installs ttyd at image build time so the terminal is reachable in a firewalled container. Never fails the build.'
69
+ };
70
+
71
+ /** Best-effort like tmux: build time is the only reliable moment to fetch in an egress-firewalled container. */
72
+ const TTYD_FEATURE_INSTALL =
73
+ '#!/bin/sh\n' +
74
+ '(\n' +
75
+ `${TTYD_INSTALL_SCRIPT}\n` +
76
+ ') || echo "codebay-ttyd: install failed (non-fatal); the manager retries after the container starts"\n' +
77
+ 'exit 0\n';
78
+
79
+ /** Relative to .devcontainer/. Only written for terminal-mode instances on a project-supplied config. */
80
+ const CLAUDE_FEATURE_DIR = 'codebay-claude';
81
+
82
+ const CLAUDE_FEATURE_METADATA = {
83
+ id: 'codebay-claude',
84
+ version: '1.0.0',
85
+ name: 'Claude Code (Codebay, best-effort)',
86
+ description:
87
+ "Installs Claude Code at image build time when the project's own image doesn't already ship it. Never fails the build."
88
+ };
89
+
90
+ /** Best-effort like tmux/ttyd: build time is the only reliable moment to fetch in an egress-firewalled container. */
91
+ const CLAUDE_FEATURE_INSTALL =
92
+ '#!/bin/sh\n' +
93
+ '(\n' +
94
+ `${CLAUDE_INSTALL_SCRIPT}\n` +
95
+ ') || echo "codebay-claude: install failed (non-fatal); the manager retries after the container starts"\n' +
96
+ 'exit 0\n';
97
+
52
98
  /** Manager-dropped files the project's own .gitignore won't cover, kept out of git status. */
53
99
  const MANAGER_GIT_EXCLUDES = [
54
100
  '/.devcontainer/code-server-settings.json',
55
101
  '/.devcontainer/devcontainer-lock.json',
56
102
  '/.devcontainer/codebay-tmux/',
103
+ '/.devcontainer/codebay-ttyd/',
104
+ '/.devcontainer/codebay-claude/',
105
+ '/.devcontainer/codebay-terminal.sh',
57
106
  '/.vscode/tasks.json'
58
107
  ];
59
108
 
@@ -86,6 +135,9 @@ const CODE_SERVER_SETTINGS = {
86
135
 
87
136
  const TMUX_SESSION = 'codebay';
88
137
 
138
+ /** The split view's right-hand pane: a plain shell, kept apart from the Claude session. */
139
+ const TMUX_SHELL_SESSION = 'codebay-shell';
140
+
89
141
  /** Written to `$HOME` when the post-up injection sequence finishes; the terminal launcher waits on it. */
90
142
  export const INJECTIONS_DONE_FILE = '.codebay-injections-done';
91
143
 
@@ -148,6 +200,52 @@ const CODE_SERVER_LAUNCH =
148
200
  `nohup code-server --bind-addr 0.0.0.0:${CODE_SERVER_PORT} --auth none ` +
149
201
  `--disable-workspace-trust \\"$PWD\\" >/tmp/code-server.log 2>&1 &"`;
150
202
 
203
+ /** Staged next to the config; ttyd runs it as its command so the nested quoting stays in a real file. */
204
+ const TTYD_LAUNCH_SCRIPT_FILE = 'codebay-terminal.sh';
205
+
206
+ /** The one `?arg=` value ttyd is allowed to act on; anything else falls through to Claude. */
207
+ export const TTYD_SHELL_ARG = 'shell';
208
+
209
+ /**
210
+ * ttyd runs this per browser connection. `tmux -A` attaches to the shared `codebay` session
211
+ * (or creates it), so Claude survives reconnects. No WAIT_FOR_IDE_BRIDGE — terminal mode has
212
+ * no code-server extension host to race. Kept in a file so the tmux command's own quoting isn't
213
+ * nested inside the postStartCommand's shell quoting.
214
+ *
215
+ * `$1` comes from the URL's `?arg=` (ttyd's --url-arg), so it is untrusted — hence a literal
216
+ * compare and never an eval. The shell branch sits above the injections wait so the split view's
217
+ * scratch shell opens immediately instead of blocking on Claude's boot sentinel.
218
+ */
219
+ const TTYD_LAUNCH_SCRIPT =
220
+ '#!/usr/bin/env bash\n' +
221
+ 'export SHELL="${SHELL:-/bin/bash}"\n' +
222
+ `if [ "$1" = "${TTYD_SHELL_ARG}" ]; then\n` +
223
+ ' if command -v tmux >/dev/null 2>&1; then\n' +
224
+ ` exec tmux new-session -A -s ${TMUX_SHELL_SESSION}\n` +
225
+ ' fi\n' +
226
+ ' exec "$SHELL" -l\n' +
227
+ 'fi\n' +
228
+ WAIT_FOR_INJECTIONS +
229
+ '\n' +
230
+ `if command -v tmux >/dev/null 2>&1; then\n` +
231
+ ` exec tmux new-session -A -s ${TMUX_SESSION} 'claude --dangerously-skip-permissions; exec "$SHELL" -l'\n` +
232
+ 'fi\n' +
233
+ 'claude --dangerously-skip-permissions\n' +
234
+ 'exec "$SHELL" -l\n';
235
+
236
+ // ttyd defaults to read-only, so --writable is required for keyboard input. Guarded by `pgrep -x`
237
+ // (process name) so a folderOpen/rebuild can't stack a second daemon — never `pgrep -f`, whose
238
+ // cmdline match also matches this very shell, which would skip the launch every time.
239
+ const TTYD_LAUNCH =
240
+ `bash -c "` +
241
+ `export SHELL=\\"\${SHELL:-/bin/bash}\\"; ` +
242
+ `command -v ttyd >/dev/null 2>&1 || exit 0; ` +
243
+ `pgrep -x ttyd >/dev/null 2>&1 || ` +
244
+ // --url-arg forwards the connection URL's `?arg=` into the launcher's argv, so one ttyd serves
245
+ // both the Claude session and the split view's scratch shell (--writable is covered above).
246
+ `nohup ttyd --port ${TTYD_PORT} --writable --url-arg ` +
247
+ `bash \\"$PWD/.devcontainer/${TTYD_LAUNCH_SCRIPT_FILE}\\" >/tmp/ttyd.log 2>&1 &"`;
248
+
151
249
  export async function devcontainerCliAvailable(): Promise<boolean> {
152
250
  return (await spawnCapture([devcontainerBin(), '--version'])) !== null;
153
251
  }
@@ -288,7 +386,15 @@ export async function readDeclaredContainerPorts(workspaceDir: string): Promise<
288
386
  const ports = new Set<number>();
289
387
  for (const entry of entries) {
290
388
  const port = containerPortOf(entry);
291
- if (Number.isInteger(port) && port > 0 && port <= 65535 && port !== CODE_SERVER_PORT) {
389
+ // Both reserved internal ports are excluded regardless of mode, so neither can be
390
+ // double-mapped when writeOverrideConfig renders its own appPort entry for it.
391
+ if (
392
+ Number.isInteger(port) &&
393
+ port > 0 &&
394
+ port <= 65535 &&
395
+ port !== CODE_SERVER_PORT &&
396
+ port !== TTYD_PORT
397
+ ) {
292
398
  ports.add(port);
293
399
  }
294
400
  }
@@ -303,8 +409,10 @@ export async function writeOverrideConfig(
303
409
  workspaceDir: string,
304
410
  hostPort: number,
305
411
  forwards: PortForward[] = [],
306
- defaultImage: string = DEFAULT_IMAGE
412
+ defaultImage: string = DEFAULT_IMAGE,
413
+ mode: InstanceMode = 'ide'
307
414
  ): Promise<{ imageSource: string }> {
415
+ const isTerminal = mode === 'terminal';
308
416
  const target = configPath(workspaceDir);
309
417
  let config: DevcontainerConfig = {};
310
418
 
@@ -326,23 +434,38 @@ export async function writeOverrideConfig(
326
434
  }
327
435
 
328
436
  // 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))}`;
330
-
331
- // Node/Claude/gh only for the default image — projects with their own config own their tooling.
437
+ const featureKey = (dir: string) =>
438
+ `./${relative(dirname(target), join(workspaceDir, '.devcontainer', dir))}`;
439
+ const tmuxFeatureKey = featureKey(TMUX_FEATURE_DIR);
440
+ const ttydFeatureKey = featureKey(TTYD_FEATURE_DIR);
441
+ const claudeFeatureKey = featureKey(CLAUDE_FEATURE_DIR);
442
+
443
+ // Terminal mode swaps code-server for ttyd; otherwise (tmux, tooling) the two are identical.
444
+ // Claude Code: the default image needs the upstream features, while terminal mode on a
445
+ // project-supplied image gets the local one, which installs only what that image lacks — the
446
+ // upstream Node feature would run nvm, which any image setting NPM_CONFIG_PREFIX breaks on.
447
+ // IDE mode on a project config stays hands-off: the project owns its tooling.
448
+ const needsClaude = isTerminal || !hadConfig;
332
449
  config.features = {
333
450
  ...(config.features ?? {}),
334
- [CODE_SERVER_FEATURE]: { host: '0.0.0.0', port: CODE_SERVER_PORT, auth: 'none' },
451
+ ...(isTerminal
452
+ ? { [ttydFeatureKey]: {} }
453
+ : { [CODE_SERVER_FEATURE]: { host: '0.0.0.0', port: CODE_SERVER_PORT, auth: 'none' } }),
335
454
  [tmuxFeatureKey]: {},
336
- ...(hadConfig
337
- ? {}
338
- : { [NODE_FEATURE]: {}, [CLAUDE_CODE_FEATURE]: {}, [GITHUB_CLI_FEATURE]: {} })
455
+ ...(needsClaude ? { [GITHUB_CLI_FEATURE]: {} } : {}),
456
+ ...(!hadConfig
457
+ ? { [NODE_FEATURE]: {}, [CLAUDE_CODE_FEATURE]: {} }
458
+ : isTerminal
459
+ ? { [claudeFeatureKey]: {} }
460
+ : {})
339
461
  };
340
462
 
463
+ const servedPort = isTerminal ? TTYD_PORT : CODE_SERVER_PORT;
341
464
  // 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.
465
+ // The served port stays loopback even under HOST=0.0.0.0 — neither code-server nor ttyd has
466
+ // auth of its own, so the LAN reaches it only through the Basic-Auth-gated /p/:id/ proxy.
344
467
  config.appPort = [
345
- `127.0.0.1:${hostPort}:${CODE_SERVER_PORT}`,
468
+ `127.0.0.1:${hostPort}:${servedPort}`,
346
469
  ...forwards.map((f) => `${PUBLISH_HOST}:${f.host_port}:${f.container_port}`)
347
470
  ];
348
471
 
@@ -351,25 +474,31 @@ export async function writeOverrideConfig(
351
474
  runArgs.add(HOST_GATEWAY_ARG);
352
475
  config.runArgs = [...runArgs];
353
476
 
477
+ const launch = isTerminal ? TTYD_LAUNCH : CODE_SERVER_LAUNCH;
354
478
  const existing = config.postStartCommand;
355
479
  config.postStartCommand =
356
- typeof existing === 'string' && existing.trim()
357
- ? `${existing} && ${CODE_SERVER_LAUNCH}`
358
- : CODE_SERVER_LAUNCH;
480
+ typeof existing === 'string' && existing.trim() ? `${existing} && ${launch}` : launch;
359
481
 
360
482
  await mkdir(join(workspaceDir, '.devcontainer'), { recursive: true }).catch(() => {});
361
483
  await writeFile(target, JSON.stringify(config, null, 2) + '\n', 'utf8');
362
484
 
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
- );
485
+ if (isTerminal) {
486
+ await writeTtydFeature(workspaceDir);
487
+ await writeTerminalLaunchScript(workspaceDir);
488
+ } else {
489
+ // Staged next to the config; CODE_SERVER_LAUNCH copies it into the user-data dir on first start.
490
+ await writeFile(
491
+ join(workspaceDir, '.devcontainer', CODE_SERVER_SETTINGS_FILE),
492
+ JSON.stringify(CODE_SERVER_SETTINGS, null, 2) + '\n',
493
+ 'utf8'
494
+ );
495
+ // The VS Code Terminal task is code-server-only; ttyd runs its own launcher script instead.
496
+ await writeTerminalTask(workspaceDir);
497
+ }
369
498
 
370
499
  await writeTmuxFeature(workspaceDir);
371
500
 
372
- await writeTerminalTask(workspaceDir);
501
+ if (isTerminal && hadConfig) await writeClaudeFeature(workspaceDir);
373
502
 
374
503
  await writeLocalGitExclude(workspaceDir);
375
504
 
@@ -460,6 +589,39 @@ async function writeTmuxFeature(workspaceDir: string): Promise<void> {
460
589
  await chmod(installPath, 0o755);
461
590
  }
462
591
 
592
+ async function writeClaudeFeature(workspaceDir: string): Promise<void> {
593
+ const dir = join(workspaceDir, '.devcontainer', CLAUDE_FEATURE_DIR);
594
+ await mkdir(dir, { recursive: true }).catch(() => {});
595
+ await writeFile(
596
+ join(dir, 'devcontainer-feature.json'),
597
+ JSON.stringify(CLAUDE_FEATURE_METADATA, null, 2) + '\n',
598
+ 'utf8'
599
+ );
600
+ const installPath = join(dir, 'install.sh');
601
+ await writeFile(installPath, CLAUDE_FEATURE_INSTALL, 'utf8');
602
+ await chmod(installPath, 0o755);
603
+ }
604
+
605
+ async function writeTtydFeature(workspaceDir: string): Promise<void> {
606
+ const dir = join(workspaceDir, '.devcontainer', TTYD_FEATURE_DIR);
607
+ await mkdir(dir, { recursive: true }).catch(() => {});
608
+ await writeFile(
609
+ join(dir, 'devcontainer-feature.json'),
610
+ JSON.stringify(TTYD_FEATURE_METADATA, null, 2) + '\n',
611
+ 'utf8'
612
+ );
613
+ const installPath = join(dir, 'install.sh');
614
+ await writeFile(installPath, TTYD_FEATURE_INSTALL, 'utf8');
615
+ await chmod(installPath, 0o755);
616
+ }
617
+
618
+ /** Staged next to the config; TTYD_LAUNCH runs it as ttyd's command. */
619
+ async function writeTerminalLaunchScript(workspaceDir: string): Promise<void> {
620
+ const path = join(workspaceDir, '.devcontainer', TTYD_LAUNCH_SCRIPT_FILE);
621
+ await writeFile(path, TTYD_LAUNCH_SCRIPT, 'utf8');
622
+ await chmod(path, 0o755);
623
+ }
624
+
463
625
  /** Replaces the managed task rather than skipping it, so a rebuild picks up command changes. */
464
626
  async function writeTerminalTask(workspaceDir: string): Promise<void> {
465
627
  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,12 +1,14 @@
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';
9
10
  import { claudeCodeIdeExtension } from '../container-injections/claude-code-ide-extension.ts';
11
+ import { claudeCodeInstall } from '../container-injections/claude-code-install.ts';
10
12
  import { claudeCodeUpdate } from '../container-injections/claude-code-update.ts';
11
13
  import { claudeCodeModels } from '../container-injections/claude-code-models.ts';
12
14
  import { githubCredentials } from '../container-injections/github-credentials.ts';
@@ -32,6 +34,8 @@ export interface Injection {
32
34
  /** Doubles as the health-row key. */
33
35
  id: string;
34
36
  label: string;
37
+ /** Restrict to specific instance modes; absent means it applies to every mode. */
38
+ modes?: InstanceMode[];
35
39
  /** Omit for injections with no host dependency — presence is what draws the setup-UI chip. */
36
40
  auth?: {
37
41
  /** Short instruction shown when unavailable, e.g. "run `gh auth login`". */
@@ -49,6 +53,10 @@ const BASE_INJECTIONS_HEAD: Injection[] = [
49
53
  gitSafeDirectory,
50
54
  // The package install is the slowest injection, so start it before a folderOpen can find tmux missing.
51
55
  tmux,
56
+ // Terminal-mode only (filtered by resolveInjections); a download, so run it early like tmux.
57
+ ttyd,
58
+ // Terminal-mode only, and ahead of every claude-* step in the tail, which all assume a binary.
59
+ claudeCodeInstall,
52
60
  gitIdentity
53
61
  ];
54
62
 
@@ -69,14 +77,19 @@ const BASE_INJECTIONS_TAIL: Injection[] = [
69
77
  hostEnvVars
70
78
  ];
71
79
 
72
- /** Resolved per call so toggling the custom-endpoint setting takes effect without a restart. */
73
- export function resolveInjections(): Injection[] {
80
+ /**
81
+ * Resolved per call so toggling the custom-endpoint setting takes effect without a restart.
82
+ * `mode` drops injections that don't apply to it (e.g. ttyd off IDE instances, the IDE
83
+ * extension off terminal ones); omit it in mode-agnostic contexts to keep every injection.
84
+ */
85
+ export function resolveInjections(mode?: InstanceMode): Injection[] {
74
86
  const claudeInjection =
75
87
  getOption('custom_endpoint_enabled') === '1' ? claudeCodeCustom : claudeCodeCredentials;
76
- return [...BASE_INJECTIONS_HEAD, claudeInjection, ...BASE_INJECTIONS_TAIL];
88
+ const all = [...BASE_INJECTIONS_HEAD, claudeInjection, ...BASE_INJECTIONS_TAIL];
89
+ return mode ? all.filter((i) => !i.modes || i.modes.includes(mode)) : all;
77
90
  }
78
91
 
79
- /** @deprecated Use `resolveInjections()`; this ignores settings and is kept for the tests. */
92
+ /** @deprecated Use `resolveInjections()`; this ignores settings/mode and is kept for the tests. */
80
93
  export const injections: Injection[] = [
81
94
  ...BASE_INJECTIONS_HEAD,
82
95
  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,15 @@ 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
+ // Forwards publish on PUBLISH_HOST (0.0.0.0 under HOST=0.0.0.0), so forwarding the port the
528
+ // instance is actually served on would republish code-server/ttyd — neither of which has auth
529
+ // of its own — outside the Basic-Auth-gated proxy. Scoped to the live surface on purpose: in
530
+ // terminal mode nothing listens on 8080, and it's far too common an app port to reserve.
531
+ const reserved = row.mode === 'terminal' ? TTYD_PORT : CODE_SERVER_PORT;
532
+ if (containerPort === reserved) {
533
+ throw new Error(
534
+ `Port ${reserved} is reserved for ${row.mode === 'terminal' ? 'the terminal' : 'code-server'}`
535
+ );
478
536
  }
479
537
  if (listForwards(id).some((f) => f.container_port === containerPort)) {
480
538
  throw new Error(`Port ${containerPort} is already forwarded`);
@@ -50,6 +50,8 @@ interface RelayState {
50
50
  client?: WebSocket;
51
51
  /** Frames received from the browser before the upstream socket is open. */
52
52
  pending: Frame[];
53
+ /** The client's requested subprotocols, forwarded upstream — ttyd requires its `tty` one. */
54
+ subprotocols?: string[];
53
55
  }
54
56
 
55
57
  async function proxyHttp(event: MochiApiEvent, port: number, rest: string): Promise<Response> {
@@ -57,7 +59,8 @@ async function proxyHttp(event: MochiApiEvent, port: number, rest: string): Prom
57
59
  headers.set('host', `127.0.0.1:${port}`);
58
60
  headers.delete('accept-encoding'); // ask for an unencoded body so we can stream it verbatim
59
61
  // code-server runs with `--auth none`, so forwarding this would only leak the app password.
60
- // `cookie` is deliberately kept: the manager sets none, so every cookie here is code-server's.
62
+ // `cookie` is deliberately kept: the manager's only same-origin cookie is the UI theme, so
63
+ // anything else here belongs to the upstream and has to survive the round trip.
61
64
  headers.delete('authorization');
62
65
  const hasBody = event.method !== 'GET' && event.method !== 'HEAD';
63
66
  let upstream: Response;
@@ -92,18 +95,34 @@ async function proxyHttp(event: MochiApiEvent, port: number, rest: string): Prom
92
95
  }
93
96
 
94
97
  function proxyUpgrade(event: MochiApiEvent, port: number, rest: string): Response {
98
+ const requested = event.request.headers.get('sec-websocket-protocol');
99
+ const subprotocols = requested
100
+ ? requested
101
+ .split(',')
102
+ .map((s) => s.trim())
103
+ .filter(Boolean)
104
+ : undefined;
95
105
  const data: MochiWsData<RelayState> = {
96
106
  __mochiRoutePattern: PROXY_WS_PATTERN,
97
107
  __mochiOpenedAt: performance.now(),
98
108
  __mochiPath: event.url.pathname,
99
- user: { upstreamWsUrl: `ws://127.0.0.1:${port}${rest}${event.url.search}`, pending: [] }
109
+ user: {
110
+ upstreamWsUrl: `ws://127.0.0.1:${port}${rest}${event.url.search}`,
111
+ pending: [],
112
+ subprotocols
113
+ }
100
114
  };
115
+ // Echo the first offered subprotocol back so the browser's handshake confirms it (ttyd offers `tty`).
116
+ const headers = subprotocols?.[0] ? { 'Sec-WebSocket-Protocol': subprotocols[0] } : undefined;
101
117
  // Bun's Server is typed with a fixed WebSocketData; Mochi casts the same way for its own upgrades.
102
118
  const ok = (
103
119
  event.server as unknown as {
104
- upgrade: (req: Request, opts: { data: MochiWsData<RelayState> }) => boolean;
120
+ upgrade: (
121
+ req: Request,
122
+ opts: { data: MochiWsData<RelayState>; headers?: Record<string, string> }
123
+ ) => boolean;
105
124
  }
106
- ).upgrade(event.request, { data });
125
+ ).upgrade(event.request, { data, headers });
107
126
  if (!ok) return new Response('WebSocket upgrade failed', { status: 426 });
108
127
  // Bun already sent the real 101; this is ignored, but Mochi's resolve chain reads a status.
109
128
  return new Response(null, { status: 101 });
@@ -137,7 +156,9 @@ export const proxyRoutes: Record<string, MochiRouteValue> = {
137
156
  upgrade: () => false,
138
157
  open(ws: ServerWebSocket<MochiWsData<RelayState>>) {
139
158
  const state = ws.data.user;
140
- const client = new WebSocket(state.upstreamWsUrl);
159
+ const client = state.subprotocols
160
+ ? new WebSocket(state.upstreamWsUrl, state.subprotocols)
161
+ : new WebSocket(state.upstreamWsUrl);
141
162
  client.binaryType = 'arraybuffer';
142
163
  state.client = client;
143
164
 
@@ -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}