codebay 0.10.0 → 0.10.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.
@@ -5,8 +5,8 @@
5
5
  "componentEntryUrls": {
6
6
  "AppShell_2rtnxn6pgp2rh": "/_mochi/client/_hydrate-AppShell_2rtnxn6pgp2rh-rx6arrv5.js",
7
7
  "InstanceView_2ma45l9bybfhl": "/_mochi/client/_hydrate-InstanceView_2ma45l9bybfhl-c9y47dat.js",
8
- "SettingsView_8hmarwmf8onq": "/_mochi/client/_hydrate-SettingsView_8hmarwmf8onq-d61feq89.js",
9
- "AvatarsView_257s9yq1wwr4f": "/_mochi/client/_hydrate-AvatarsView_257s9yq1wwr4f-jpjdmgpe.js"
8
+ "AvatarsView_257s9yq1wwr4f": "/_mochi/client/_hydrate-AvatarsView_257s9yq1wwr4f-jpjdmgpe.js",
9
+ "SettingsView_8hmarwmf8onq": "/_mochi/client/_hydrate-SettingsView_8hmarwmf8onq-d61feq89.js"
10
10
  },
11
11
  "cssFileUrls": {
12
12
  "$mochi/templates/DefaultError.svelte": "/_mochi/css/DefaultError-2b0daamydxgb2.css",
@@ -14,8 +14,8 @@
14
14
  "$mochi/templates/BackLink.svelte": "/_mochi/css/BackLink-2wlu0eovz9j86.css",
15
15
  "src/components/AppShell.svelte": "/_mochi/css/AppShell-36mpg5smnbmuw.css",
16
16
  "src/components/InstanceView.svelte": "/_mochi/css/InstanceView-1pd7j16bd2b16.css",
17
- "src/components/SettingsView.svelte": "/_mochi/css/SettingsView-n3oy75uonzp2.css",
18
17
  "src/components/AvatarsView.svelte": "/_mochi/css/AvatarsView-2llo48w0wt0xn.css",
18
+ "src/components/SettingsView.svelte": "/_mochi/css/SettingsView-n3oy75uonzp2.css",
19
19
  "src/components/TerminalSplit.svelte": "/_mochi/css/TerminalSplit-2416xfuln89a3.css",
20
20
  "src/components/DashboardView.svelte": "/_mochi/css/DashboardView-1cy4xpqhja9sx.css",
21
21
  "src/components/IdeBar.svelte": "/_mochi/css/IdeBar-3bgr3m5z0lpeg.css",
@@ -28,25 +28,25 @@
28
28
  "node_modules/svelte-french-toast/dist/components/ToastBar.svelte": "/_mochi/css/ToastBar-3havgkmtofntq.css",
29
29
  "src/components/Skeleton.svelte": "/_mochi/css/Skeleton-8k69usiju43h.css",
30
30
  "src/components/StatusBadge.svelte": "/_mochi/css/StatusBadge-3mq0j7z6aq4j.css",
31
- "src/components/HealthBox.svelte": "/_mochi/css/HealthBox-er2her0jukwl.css",
32
31
  "src/components/AppBar.svelte": "/_mochi/css/AppBar-3bxinv4pabv8l.css",
32
+ "src/components/AvatarEditor.svelte": "/_mochi/css/AvatarEditor-2mdq8ss346drw.css",
33
33
  "src/components/Avatar.svelte": "/_mochi/css/Avatar-3pi30db6ozvf0.css",
34
+ "src/components/HealthBox.svelte": "/_mochi/css/HealthBox-er2her0jukwl.css",
34
35
  "src/components/Button.svelte": "/_mochi/css/Button-oharop2xz6am.css",
35
- "src/components/CoinButton.svelte": "/_mochi/css/CoinButton-1rej3sh3tkjud.css",
36
36
  "src/components/ThemePicker.svelte": "/_mochi/css/ThemePicker-1ir8jyocm8mvi.css",
37
- "src/components/AvatarEditor.svelte": "/_mochi/css/AvatarEditor-2mdq8ss346drw.css",
37
+ "src/components/CoinButton.svelte": "/_mochi/css/CoinButton-1rej3sh3tkjud.css",
38
38
  "src/components/TerminalPane.svelte": "/_mochi/css/TerminalPane-3bwgk6ulzq6b3.css",
39
- "src/components/FolderBrowser.svelte": "/_mochi/css/FolderBrowser-2pxp9z1o7zixk.css",
40
- "src/components/InstanceCard.svelte": "/_mochi/css/InstanceCard-3vnltxnbxkvay.css",
39
+ "node_modules/svelte-french-toast/dist/components/ToastWrapper.svelte": "/_mochi/css/ToastWrapper-1fbnym10rm2nv.css",
41
40
  "src/components/TopBar.svelte": "/_mochi/css/TopBar-2y76zxwehrfos.css",
41
+ "src/components/InstanceCard.svelte": "/_mochi/css/InstanceCard-3vnltxnbxkvay.css",
42
+ "src/components/FolderBrowser.svelte": "/_mochi/css/FolderBrowser-2pxp9z1o7zixk.css",
42
43
  "node_modules/svelte-french-toast/dist/components/ToastMessage.svelte": "/_mochi/css/ToastMessage-2jfwb2og2lfa8.css",
43
- "node_modules/svelte-french-toast/dist/components/ToastWrapper.svelte": "/_mochi/css/ToastWrapper-1fbnym10rm2nv.css",
44
- "src/components/BranchBox.svelte": "/_mochi/css/BranchBox-1yqu1q2mgnjwz.css",
45
- "src/components/PortsBox.svelte": "/_mochi/css/PortsBox-1t25b5yg7jva3.css",
46
44
  "src/components/Brand.svelte": "/_mochi/css/Brand-1bh2b800g5vrn.css",
47
- "src/components/InstanceFilter.svelte": "/_mochi/css/InstanceFilter-3q6x0jt0hcgpz.css",
45
+ "src/components/PortsBox.svelte": "/_mochi/css/PortsBox-1t25b5yg7jva3.css",
46
+ "src/components/BranchBox.svelte": "/_mochi/css/BranchBox-1yqu1q2mgnjwz.css",
47
+ "src/components/SettingsCog.svelte": "/_mochi/css/SettingsCog-jftyadvo64qo.css",
48
48
  "src/components/CredMenu.svelte": "/_mochi/css/CredMenu-9hwgeptp7crb.css",
49
- "src/components/SettingsCog.svelte": "/_mochi/css/SettingsCog-jftyadvo64qo.css"
49
+ "src/components/InstanceFilter.svelte": "/_mochi/css/InstanceFilter-3q6x0jt0hcgpz.css"
50
50
  },
51
51
  "clientFiles": {
52
52
  "/_mochi/css/DefaultError-2b0daamydxgb2.css": "svelte-css/DefaultError-2b0daamydxgb2.css",
@@ -54,8 +54,8 @@
54
54
  "/_mochi/css/BackLink-2wlu0eovz9j86.css": "svelte-css/BackLink-2wlu0eovz9j86.css",
55
55
  "/_mochi/css/AppShell-36mpg5smnbmuw.css": "svelte-css/AppShell-36mpg5smnbmuw.css",
56
56
  "/_mochi/css/InstanceView-1pd7j16bd2b16.css": "svelte-css/InstanceView-1pd7j16bd2b16.css",
57
- "/_mochi/css/SettingsView-n3oy75uonzp2.css": "svelte-css/SettingsView-n3oy75uonzp2.css",
58
57
  "/_mochi/css/AvatarsView-2llo48w0wt0xn.css": "svelte-css/AvatarsView-2llo48w0wt0xn.css",
58
+ "/_mochi/css/SettingsView-n3oy75uonzp2.css": "svelte-css/SettingsView-n3oy75uonzp2.css",
59
59
  "/_mochi/css/TerminalSplit-2416xfuln89a3.css": "svelte-css/TerminalSplit-2416xfuln89a3.css",
60
60
  "/_mochi/css/DashboardView-1cy4xpqhja9sx.css": "svelte-css/DashboardView-1cy4xpqhja9sx.css",
61
61
  "/_mochi/css/IdeBar-3bgr3m5z0lpeg.css": "svelte-css/IdeBar-3bgr3m5z0lpeg.css",
@@ -68,25 +68,25 @@
68
68
  "/_mochi/css/ToastBar-3havgkmtofntq.css": "svelte-css/ToastBar-3havgkmtofntq.css",
69
69
  "/_mochi/css/Skeleton-8k69usiju43h.css": "svelte-css/Skeleton-8k69usiju43h.css",
70
70
  "/_mochi/css/StatusBadge-3mq0j7z6aq4j.css": "svelte-css/StatusBadge-3mq0j7z6aq4j.css",
71
- "/_mochi/css/HealthBox-er2her0jukwl.css": "svelte-css/HealthBox-er2her0jukwl.css",
72
71
  "/_mochi/css/AppBar-3bxinv4pabv8l.css": "svelte-css/AppBar-3bxinv4pabv8l.css",
72
+ "/_mochi/css/AvatarEditor-2mdq8ss346drw.css": "svelte-css/AvatarEditor-2mdq8ss346drw.css",
73
73
  "/_mochi/css/Avatar-3pi30db6ozvf0.css": "svelte-css/Avatar-3pi30db6ozvf0.css",
74
+ "/_mochi/css/HealthBox-er2her0jukwl.css": "svelte-css/HealthBox-er2her0jukwl.css",
74
75
  "/_mochi/css/Button-oharop2xz6am.css": "svelte-css/Button-oharop2xz6am.css",
75
- "/_mochi/css/CoinButton-1rej3sh3tkjud.css": "svelte-css/CoinButton-1rej3sh3tkjud.css",
76
76
  "/_mochi/css/ThemePicker-1ir8jyocm8mvi.css": "svelte-css/ThemePicker-1ir8jyocm8mvi.css",
77
- "/_mochi/css/AvatarEditor-2mdq8ss346drw.css": "svelte-css/AvatarEditor-2mdq8ss346drw.css",
77
+ "/_mochi/css/CoinButton-1rej3sh3tkjud.css": "svelte-css/CoinButton-1rej3sh3tkjud.css",
78
78
  "/_mochi/css/TerminalPane-3bwgk6ulzq6b3.css": "svelte-css/TerminalPane-3bwgk6ulzq6b3.css",
79
- "/_mochi/css/FolderBrowser-2pxp9z1o7zixk.css": "svelte-css/FolderBrowser-2pxp9z1o7zixk.css",
80
- "/_mochi/css/InstanceCard-3vnltxnbxkvay.css": "svelte-css/InstanceCard-3vnltxnbxkvay.css",
79
+ "/_mochi/css/ToastWrapper-1fbnym10rm2nv.css": "svelte-css/ToastWrapper-1fbnym10rm2nv.css",
81
80
  "/_mochi/css/TopBar-2y76zxwehrfos.css": "svelte-css/TopBar-2y76zxwehrfos.css",
81
+ "/_mochi/css/InstanceCard-3vnltxnbxkvay.css": "svelte-css/InstanceCard-3vnltxnbxkvay.css",
82
+ "/_mochi/css/FolderBrowser-2pxp9z1o7zixk.css": "svelte-css/FolderBrowser-2pxp9z1o7zixk.css",
82
83
  "/_mochi/css/ToastMessage-2jfwb2og2lfa8.css": "svelte-css/ToastMessage-2jfwb2og2lfa8.css",
83
- "/_mochi/css/ToastWrapper-1fbnym10rm2nv.css": "svelte-css/ToastWrapper-1fbnym10rm2nv.css",
84
- "/_mochi/css/BranchBox-1yqu1q2mgnjwz.css": "svelte-css/BranchBox-1yqu1q2mgnjwz.css",
85
- "/_mochi/css/PortsBox-1t25b5yg7jva3.css": "svelte-css/PortsBox-1t25b5yg7jva3.css",
86
84
  "/_mochi/css/Brand-1bh2b800g5vrn.css": "svelte-css/Brand-1bh2b800g5vrn.css",
87
- "/_mochi/css/InstanceFilter-3q6x0jt0hcgpz.css": "svelte-css/InstanceFilter-3q6x0jt0hcgpz.css",
88
- "/_mochi/css/CredMenu-9hwgeptp7crb.css": "svelte-css/CredMenu-9hwgeptp7crb.css",
85
+ "/_mochi/css/PortsBox-1t25b5yg7jva3.css": "svelte-css/PortsBox-1t25b5yg7jva3.css",
86
+ "/_mochi/css/BranchBox-1yqu1q2mgnjwz.css": "svelte-css/BranchBox-1yqu1q2mgnjwz.css",
89
87
  "/_mochi/css/SettingsCog-jftyadvo64qo.css": "svelte-css/SettingsCog-jftyadvo64qo.css",
88
+ "/_mochi/css/CredMenu-9hwgeptp7crb.css": "svelte-css/CredMenu-9hwgeptp7crb.css",
89
+ "/_mochi/css/InstanceFilter-3q6x0jt0hcgpz.css": "svelte-css/InstanceFilter-3q6x0jt0hcgpz.css",
90
90
  "/_mochi/import-css/index-0nwv3654.css": "import-css/index-0nwv3654.css",
91
91
  "/_mochi/import-css/index-xndx4980.css": "import-css/index-xndx4980.css",
92
92
  "/_mochi/import-css/xterm-1yy1hf1g.css": "import-css/xterm-1yy1hf1g.css",
@@ -101,8 +101,8 @@
101
101
  "/_mochi/client/chunk-wvtkwfsh.js": "svelte-client/chunk-wvtkwfsh.js",
102
102
  "/_mochi/client/_hydrate-AppShell_2rtnxn6pgp2rh-rx6arrv5.js": "svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-rx6arrv5.js",
103
103
  "/_mochi/client/_hydrate-InstanceView_2ma45l9bybfhl-c9y47dat.js": "svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-c9y47dat.js",
104
- "/_mochi/client/_hydrate-SettingsView_8hmarwmf8onq-d61feq89.js": "svelte-client/_hydrate-SettingsView_8hmarwmf8onq-d61feq89.js",
105
104
  "/_mochi/client/_hydrate-AvatarsView_257s9yq1wwr4f-jpjdmgpe.js": "svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-jpjdmgpe.js",
105
+ "/_mochi/client/_hydrate-SettingsView_8hmarwmf8onq-d61feq89.js": "svelte-client/_hydrate-SettingsView_8hmarwmf8onq-d61feq89.js",
106
106
  "/_mochi/client/chunk-p20tkpde.js": "svelte-client/chunk-p20tkpde.js",
107
107
  "/_mochi/client/chunk-0fjx3z3r.js": "svelte-client/chunk-0fjx3z3r.js"
108
108
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebay",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Web UI for spinning up isolated devcontainer instances from any local folder, each with a browser-based VS Code (code-server) — built for parallel Claude Code workflows.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -150,6 +150,16 @@ export const INJECTIONS_DONE_FILE = '.codebay-injections-done';
150
150
  */
151
151
  export const TERMINAL_LAUNCHED_MARKER = '.codebay-terminal-launched';
152
152
 
153
+ /**
154
+ * Far above the ~107s a real injection phase measures: at 60s this expired mid-phase and released
155
+ * the launcher into the window where `claude-code-update`'s `npm install -g` had unlinked the
156
+ * binary. `provision()` writes the sentinel on its failure path too, so a dead boot never waits here.
157
+ */
158
+ const INJECTIONS_WAIT_SECONDS = 600;
159
+
160
+ /** Long enough to ride out an in-flight `npm install -g` of Claude Code. */
161
+ const CLAUDE_BINARY_WAIT_SECONDS = 60;
162
+
153
163
  /**
154
164
  * An auto-launched `claude` that starts mid-injection reads `~/.claude.json` before the trust
155
165
  * keys land and clobbers them on its next rewrite, so hold it until the sentinel appears.
@@ -157,7 +167,20 @@ export const TERMINAL_LAUNCHED_MARKER = '.codebay-terminal-launched';
157
167
  */
158
168
  const WAIT_FOR_INJECTIONS =
159
169
  `[ -e "$HOME/${INJECTIONS_DONE_FILE}" ] || echo "Waiting for codebay setup to finish…"; ` +
160
- `i=0; until [ -e "$HOME/${INJECTIONS_DONE_FILE}" ] || [ "$i" -ge 60 ]; do sleep 1; i=$((i + 1)); done; `;
170
+ `i=0; until [ -e "$HOME/${INJECTIONS_DONE_FILE}" ] || [ "$i" -ge ${INJECTIONS_WAIT_SECONDS} ]; do sleep 1; i=$((i + 1)); done; ` +
171
+ `[ -e "$HOME/${INJECTIONS_DONE_FILE}" ] || echo "codebay: setup did not finish in time — starting anyway"; `;
172
+
173
+ /**
174
+ * Never exec a binary that may not be there: the update injection unlinks `claude` while it
175
+ * reinstalls, and an IDE-mode project image is free to ship no Claude Code at all — both used to
176
+ * surface as a bare `command not found` under the "Waiting for codebay setup" line. The retry is
177
+ * skipped once the sentinel exists, since no injection can be mid-reinstall by then and a missing
178
+ * binary is permanent. No single quotes: this is spliced into the single-quoted tmux commands below.
179
+ */
180
+ const launchClaude = (permissionMode: ClaudePermissionMode): string =>
181
+ `if [ ! -e "$HOME/${INJECTIONS_DONE_FILE}" ]; then i=0; until command -v claude >/dev/null 2>&1 || [ "$i" -ge ${CLAUDE_BINARY_WAIT_SECONDS} ]; do sleep 1; i=$((i + 1)); done; fi; ` +
182
+ `if command -v claude >/dev/null 2>&1; then claude ${claudePermissionFlags(permissionMode)}; ` +
183
+ `else echo "codebay: claude is not installed in this container — check the boot log, then Rebuild"; fi; `;
161
184
 
162
185
  /**
163
186
  * claude scans `~/.claude/ide/*.lock` once at startup and never retries, so it must not race
@@ -192,7 +215,7 @@ const terminalTask = (permissionMode: ClaudePermissionMode) => ({
192
215
  type: 'shell',
193
216
  command:
194
217
  // `"$SHELL"` must reach tmux unexpanded; VS Code would substitute a `${…}` form first.
195
- `if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s ${TMUX_SESSION} '${WAIT_FOR_INJECTIONS}${WAIT_FOR_IDE_BRIDGE}${SOURCE_INJECTED_ENV}claude ${claudePermissionFlags(permissionMode)}; exec "$SHELL" -l'; fi; ` +
218
+ `if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s ${TMUX_SESSION} '${WAIT_FOR_INJECTIONS}${WAIT_FOR_IDE_BRIDGE}${SOURCE_INJECTED_ENV}${launchClaude(permissionMode)}exec "$SHELL" -l'; fi; ` +
196
219
  // Without tmux there's no run-once gate, and folderOpen re-fires on every workspace load.
197
220
  'MARK="$HOME/' +
198
221
  TERMINAL_LAUNCHED_MARKER +
@@ -200,7 +223,8 @@ const terminalTask = (permissionMode: ClaudePermissionMode) => ({
200
223
  WAIT_FOR_INJECTIONS +
201
224
  WAIT_FOR_IDE_BRIDGE +
202
225
  SOURCE_INJECTED_ENV +
203
- `claude ${claudePermissionFlags(permissionMode)}; exec \${env:SHELL} -l`,
226
+ launchClaude(permissionMode) +
227
+ 'exec ${env:SHELL} -l',
204
228
  presentation: { reveal: 'always', panel: 'shared', focus: true },
205
229
  runOptions: { runOn: 'folderOpen' },
206
230
  problemMatcher: []
@@ -273,11 +297,12 @@ const ttydLaunchScript = (permissionMode: ClaudePermissionMode): string =>
273
297
  WAIT_FOR_INJECTIONS +
274
298
  '\n' +
275
299
  `if command -v tmux >/dev/null 2>&1; then\n` +
276
- ` exec tmux new-session -A -s ${TMUX_SESSION} '${SOURCE_INJECTED_ENV}claude ${claudePermissionFlags(permissionMode)}; exec "$SHELL" -l'\n` +
300
+ ` exec tmux new-session -A -s ${TMUX_SESSION} '${SOURCE_INJECTED_ENV}${launchClaude(permissionMode)}exec "$SHELL" -l'\n` +
277
301
  'fi\n' +
278
302
  SOURCE_INJECTED_ENV +
279
303
  '\n' +
280
- `claude ${claudePermissionFlags(permissionMode)}\n` +
304
+ launchClaude(permissionMode) +
305
+ '\n' +
281
306
  'exec "$SHELL" -l\n';
282
307
 
283
308
  // ttyd defaults to read-only, so --writable is required for keyboard input. Guarded by `pgrep -x`
@@ -47,7 +47,7 @@ import {
47
47
  writeOverrideConfig
48
48
  } from './devcontainer.server.ts';
49
49
  import { writeContainerFile } from './container-files.server.ts';
50
- import { execInContainer } from './exec.server.ts';
50
+ import { execInContainer, type ExecTarget } from './exec.server.ts';
51
51
  import { clearAttention, getAttention } from './bridge.server.ts';
52
52
  import { proxyPathFor } from './proxy.server.ts';
53
53
  import { resolveInjectionStages } from './injections.server.ts';
@@ -97,6 +97,27 @@ interface LiveState {
97
97
  const globalForReg = globalThis as unknown as { __codebayRegistry?: Map<string, LiveState> };
98
98
  const registry: Map<string, LiveState> = (globalForReg.__codebayRegistry ??= new Map());
99
99
 
100
+ /**
101
+ * The ids `provision()` is currently building. A `creating` row is only left alone while its boot
102
+ * is actually in flight — otherwise a server restart mid-boot would strand it there forever, since
103
+ * reconcile skips `creating` and `rebuildInstance` refuses to touch it.
104
+ */
105
+ const globalForBoots = globalThis as unknown as { __codebayProvisioning?: Set<string> };
106
+ const inFlight: Set<string> = (globalForBoots.__codebayProvisioning ??= new Set());
107
+
108
+ /**
109
+ * Whether reconcile may re-derive a row's status from Docker. A live boot owns its own row until it
110
+ * opens the instance itself; an orphaned `creating` row (the manager restarted mid-boot) must not
111
+ * be left there, since nothing else would ever move it and Rebuild refuses to touch a booting row.
112
+ */
113
+ export function reconcilesFromDocker(
114
+ row: Pick<InstanceRow, 'id' | 'status'>,
115
+ building: ReadonlySet<string>
116
+ ): boolean {
117
+ if (row.status === 'error') return false;
118
+ return row.status !== 'creating' || !building.has(row.id);
119
+ }
120
+
100
121
  function live(id: string): LiveState {
101
122
  let state = registry.get(id);
102
123
  if (!state) {
@@ -333,6 +354,9 @@ function failInstance(id: string, err: unknown): void {
333
354
  }
334
355
 
335
356
  async function boot(row: InstanceRow, opts: { branch?: string } = {}): Promise<void> {
357
+ // Claims the row for the copy/clone phase, which runs ahead of the build; provision() re-claims
358
+ // it and owns the release from there.
359
+ inFlight.add(row.id);
336
360
  try {
337
361
  const sourceStart = Date.now();
338
362
  if (isRepoUrl(row.source_path)) {
@@ -349,6 +373,7 @@ async function boot(row: InstanceRow, opts: { branch?: string } = {}): Promise<v
349
373
  }
350
374
  await seedDeclaredPorts(row);
351
375
  } catch (err) {
376
+ inFlight.delete(row.id);
352
377
  failInstance(row.id, err);
353
378
  return;
354
379
  }
@@ -396,6 +421,9 @@ async function rescueHijackedPort(row: InstanceRow): Promise<void> {
396
421
 
397
422
  /** Never re-copies the workspace, so in-container edits survive a rebuild. */
398
423
  async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Promise<void> {
424
+ // Held so the `catch` can release the container-side launcher's wait on a boot that dies.
425
+ let containerId: string | null = null;
426
+ inFlight.add(row.id);
399
427
  try {
400
428
  // On a rebuild `devcontainer up --remove-existing-container` discards the old container's
401
429
  // ~/.claude, so drain it first; a first boot has no container_id and skips straight past.
@@ -437,11 +465,13 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
437
465
  );
438
466
  }
439
467
 
468
+ containerId = result.containerId;
469
+ // The row deliberately stays `creating` until the injections below finish: `running` is what
470
+ // opens the IDE/terminal to the user, and a container whose setup is still landing isn't ready.
440
471
  updateInstance(row.id, {
441
472
  container_id: result.containerId,
442
473
  remote_workspace_folder: result.remoteWorkspaceFolder ?? null,
443
474
  remote_user: result.remoteUser ?? null,
444
- status: 'running',
445
475
  error: null
446
476
  });
447
477
 
@@ -450,11 +480,6 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
450
480
  row.container_id = result.containerId;
451
481
  row.remote_workspace_folder = result.remoteWorkspaceFolder ?? null;
452
482
  row.remote_user = result.remoteUser ?? null;
453
- row.status = 'running';
454
-
455
- // Start the health monitor now so the IDE mounts as soon as code-server answers,
456
- // instead of after every injection plus a full reconcile/health poll cycle.
457
- triggerReconcile();
458
483
 
459
484
  const target = {
460
485
  containerId: result.containerId,
@@ -478,22 +503,43 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
478
503
  appendLog(row.id, `⏱ injections: ${elapsed(injectionsStart)}\n`);
479
504
 
480
505
  // Unblocks the terminal launcher's wait; written even after ⚠s — its timeout is the fallback.
481
- await writeContainerFile(target, { dir: '$HOME', name: INJECTIONS_DONE_FILE }, 'done\n').catch(
482
- () => undefined
483
- );
506
+ await writeInjectionsDone(row.id, target);
484
507
 
485
508
  // postStartCommand ran before the injections above, and its launcher bails when the binary
486
509
  // is missing — so a container whose build-time ttyd install failed got it too late to be
487
510
  // started. No-ops (one accessibility probe) whenever postStart already brought the port up.
488
511
  await relaunchSurface(row);
489
512
 
513
+ // Everything the instance needs is in place, so open it to the user: this is what lights up
514
+ // Open IDE/terminal, the proxy, and the health and log-capture monitors.
515
+ updateInstance(row.id, { status: 'running' });
516
+ row.status = 'running';
517
+
490
518
  appendLog(row.id, `\n✓ Instance running — open it via the proxy at ${proxyPathFor(row.id)}\n`);
491
519
  triggerReconcile();
492
520
  } catch (err) {
521
+ // The launcher waits minutes for the sentinel, so a boot that will never write it must say so.
522
+ if (containerId)
523
+ await writeInjectionsDone(row.id, { containerId, remoteUser: row.remote_user });
493
524
  failInstance(row.id, err);
525
+ } finally {
526
+ inFlight.delete(row.id);
494
527
  }
495
528
  }
496
529
 
530
+ /**
531
+ * `writeContainerFile` reports failure by return value rather than throwing, and a sentinel that
532
+ * never lands leaves the launcher waiting out its whole bound — so surface it in the boot log.
533
+ */
534
+ async function writeInjectionsDone(id: string, target: ExecTarget): Promise<void> {
535
+ const res = await writeContainerFile(
536
+ target,
537
+ { dir: '$HOME', name: INJECTIONS_DONE_FILE },
538
+ 'done\n'
539
+ ).catch((err: Error) => ({ ok: false, error: err.message }));
540
+ if (!res.ok) appendLog(id, `⚠ Could not mark setup complete in the container: ${res.error}\n`);
541
+ }
542
+
497
543
  /** The first instance keeps the bare name; later collisions get a `#2`, `#3`, … suffix. */
498
544
  function uniqueName(desired: string, excludeId?: string): string {
499
545
  const taken = new Set(
@@ -563,10 +609,19 @@ export async function listInstances(): Promise<Instance[]> {
563
609
  const branches = new Map<string, string | null>();
564
610
  await Promise.all(
565
611
  rows.map(async (row) => {
566
- if (!row.container_id || row.status === 'creating' || row.status === 'error') return;
612
+ if (!row.container_id || !reconcilesFromDocker(row, inFlight)) return;
567
613
  const running = await isRunning(row.container_id);
568
614
  const next: InstanceStatus = running ? 'running' : 'stopped';
569
615
  if (next !== row.status) {
616
+ // Only reachable for a `creating` row whose boot died with the manager, and its
617
+ // injections will never finish — so stop the container-side launcher waiting on them.
618
+ if (row.status === 'creating') {
619
+ appendLog(row.id, `\n⚠ Setup was interrupted — Rebuild to run it again\n`);
620
+ void writeInjectionsDone(row.id, {
621
+ containerId: row.container_id,
622
+ remoteUser: row.remote_user
623
+ });
624
+ }
570
625
  updateInstance(row.id, { status: next });
571
626
  row.status = next;
572
627
  }
@@ -672,7 +727,10 @@ export function removeForwardedPort(id: string, containerPort: number): Instance
672
727
  export function rebuildInstance(id: string, opts: { noCache?: boolean } = {}): InstanceRow {
673
728
  const row = getInstance(id);
674
729
  if (!row) throw new Error('Instance not found');
675
- if (row.status === 'creating') return row; // a build is already in flight
730
+ if (inFlight.has(id)) return row; // a build is already in flight
731
+ // Claimed before the broadcast below, which reconciles synchronously: an unclaimed `creating`
732
+ // row reads as orphaned, and reconcile would flip it straight back off `creating`.
733
+ inFlight.add(id);
676
734
  updateInstance(id, { status: 'creating', error: null });
677
735
  triggerReconcile();
678
736
  const fresh = getInstance(id)!;