aicodeman 1.3.5 → 1.4.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 (111) hide show
  1. package/README.md +16 -0
  2. package/dist/docker-export.d.ts +107 -0
  3. package/dist/docker-export.d.ts.map +1 -0
  4. package/dist/docker-export.js +335 -0
  5. package/dist/docker-export.js.map +1 -0
  6. package/dist/docker-hosts.d.ts +264 -0
  7. package/dist/docker-hosts.d.ts.map +1 -0
  8. package/dist/docker-hosts.js +744 -0
  9. package/dist/docker-hosts.js.map +1 -0
  10. package/dist/mux-interface.d.ts +7 -1
  11. package/dist/mux-interface.d.ts.map +1 -1
  12. package/dist/session.d.ts +9 -2
  13. package/dist/session.d.ts.map +1 -1
  14. package/dist/session.js +48 -4
  15. package/dist/session.js.map +1 -1
  16. package/dist/tmux-manager.d.ts +58 -1
  17. package/dist/tmux-manager.d.ts.map +1 -1
  18. package/dist/tmux-manager.js +247 -7
  19. package/dist/tmux-manager.js.map +1 -1
  20. package/dist/types/api.d.ts +9 -1
  21. package/dist/types/api.d.ts.map +1 -1
  22. package/dist/types/api.js.map +1 -1
  23. package/dist/types/session.d.ts +102 -0
  24. package/dist/types/session.d.ts.map +1 -1
  25. package/dist/types/session.js.map +1 -1
  26. package/dist/web/network-auth-policy.d.ts +9 -0
  27. package/dist/web/network-auth-policy.d.ts.map +1 -1
  28. package/dist/web/network-auth-policy.js +12 -0
  29. package/dist/web/network-auth-policy.js.map +1 -1
  30. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  31. package/dist/web/public/{app.6890f3d0.js → app.6c4b9893.js} +1 -1
  32. package/dist/web/public/app.6c4b9893.js.br +0 -0
  33. package/dist/web/public/app.6c4b9893.js.gz +0 -0
  34. package/dist/web/public/{constants.5b18ed64.js → constants.48da7f03.js} +7 -0
  35. package/dist/web/public/constants.48da7f03.js.br +0 -0
  36. package/dist/web/public/{constants.5b18ed64.js.gz → constants.48da7f03.js.gz} +0 -0
  37. package/dist/web/public/cron-ui.js.gz +0 -0
  38. package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
  39. package/dist/web/public/index.html +148 -11
  40. package/dist/web/public/index.html.br +0 -0
  41. package/dist/web/public/index.html.gz +0 -0
  42. package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
  43. package/dist/web/public/keyboard-accessory.3f91acef.js.gz +0 -0
  44. package/dist/web/public/mobile-handlers.b5a667ef.js.gz +0 -0
  45. package/dist/web/public/mobile.0644e3ac.css.gz +0 -0
  46. package/dist/web/public/notification-manager.ca9dc40d.js.gz +0 -0
  47. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  48. package/dist/web/public/{panels-ui.ca03853f.js → panels-ui.3c9b0465.js} +53 -53
  49. package/dist/web/public/panels-ui.3c9b0465.js.br +0 -0
  50. package/dist/web/public/panels-ui.3c9b0465.js.gz +0 -0
  51. package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
  52. package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
  53. package/dist/web/public/respawn-ui.2d249da9.js.gz +0 -0
  54. package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
  55. package/dist/web/public/session-ui.4851c61a.js +58 -0
  56. package/dist/web/public/session-ui.4851c61a.js.br +0 -0
  57. package/dist/web/public/session-ui.4851c61a.js.gz +0 -0
  58. package/dist/web/public/settings-ui.29214cf1.js +67 -0
  59. package/dist/web/public/settings-ui.29214cf1.js.br +0 -0
  60. package/dist/web/public/settings-ui.29214cf1.js.gz +0 -0
  61. package/dist/web/public/{styles.3d78fd0d.css → styles.ff43f80f.css} +1 -1
  62. package/dist/web/public/styles.ff43f80f.css.br +0 -0
  63. package/dist/web/public/styles.ff43f80f.css.gz +0 -0
  64. package/dist/web/public/subagent-windows.69b60c35.js.gz +0 -0
  65. package/dist/web/public/sw.js.gz +0 -0
  66. package/dist/web/public/terminal-ui.efda6083.js.gz +0 -0
  67. package/dist/web/public/ultracode-panel.js.gz +0 -0
  68. package/dist/web/public/ultracode-windows.js.gz +0 -0
  69. package/dist/web/public/upload.html.gz +0 -0
  70. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  71. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  72. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  73. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  74. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  75. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  76. package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
  77. package/dist/web/public/vendor/xterm.css.gz +0 -0
  78. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  79. package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
  80. package/dist/web/routes/case-routes.d.ts.map +1 -1
  81. package/dist/web/routes/case-routes.js +422 -3
  82. package/dist/web/routes/case-routes.js.map +1 -1
  83. package/dist/web/routes/session-routes.d.ts.map +1 -1
  84. package/dist/web/routes/session-routes.js +85 -5
  85. package/dist/web/routes/session-routes.js.map +1 -1
  86. package/dist/web/schemas.d.ts +72 -1
  87. package/dist/web/schemas.d.ts.map +1 -1
  88. package/dist/web/schemas.js +158 -0
  89. package/dist/web/schemas.js.map +1 -1
  90. package/dist/web/server.d.ts +11 -0
  91. package/dist/web/server.d.ts.map +1 -1
  92. package/dist/web/server.js +81 -1
  93. package/dist/web/server.js.map +1 -1
  94. package/dist/web/sse-events.d.ts +21 -0
  95. package/dist/web/sse-events.d.ts.map +1 -1
  96. package/dist/web/sse-events.js +23 -0
  97. package/dist/web/sse-events.js.map +1 -1
  98. package/package.json +1 -1
  99. package/dist/web/public/app.6890f3d0.js.br +0 -0
  100. package/dist/web/public/app.6890f3d0.js.gz +0 -0
  101. package/dist/web/public/constants.5b18ed64.js.br +0 -0
  102. package/dist/web/public/panels-ui.ca03853f.js.br +0 -0
  103. package/dist/web/public/panels-ui.ca03853f.js.gz +0 -0
  104. package/dist/web/public/session-ui.c9094fe9.js +0 -49
  105. package/dist/web/public/session-ui.c9094fe9.js.br +0 -0
  106. package/dist/web/public/session-ui.c9094fe9.js.gz +0 -0
  107. package/dist/web/public/settings-ui.9538a0c1.js +0 -67
  108. package/dist/web/public/settings-ui.9538a0c1.js.br +0 -0
  109. package/dist/web/public/settings-ui.9538a0c1.js.gz +0 -0
  110. package/dist/web/public/styles.3d78fd0d.css.br +0 -0
  111. package/dist/web/public/styles.3d78fd0d.css.gz +0 -0
Binary file
Binary file
Binary file
@@ -1 +1 @@
1
- {"version":3,"file":"case-routes.d.ts","sourceRoot":"","sources":["../../../src/web/routes/case-routes.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAkB1C,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AA2B/D,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,SAAS,GAAG,UAAU,GAAG,IAAI,CAujB1F"}
1
+ {"version":3,"file":"case-routes.d.ts","sourceRoot":"","sources":["../../../src/web/routes/case-routes.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AA0B1C,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAiG/D,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,SAAS,GAAG,UAAU,GAAG,IAAI,CA89B1F"}
@@ -3,21 +3,39 @@
3
3
  * Handles CRUD for cases (directories under ~/codeman-cases and linked folders),
4
4
  * fix-plan reading, and ralph-wizard file serving.
5
5
  */
6
- import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
6
+ import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, createReadStream } from 'node:fs';
7
+ import { exec } from 'node:child_process';
7
8
  import fs from 'node:fs/promises';
8
- import { join, resolve } from 'node:path';
9
+ import { join, resolve, basename } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
9
11
  import { homedir } from 'node:os';
10
12
  import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
11
- import { CreateCaseSchema, LinkCaseSchema, CaseOrderSchema, RemoteCaseLinkSchema, RemoteHostSchema, } from '../schemas.js';
13
+ import { CreateCaseSchema, LinkCaseSchema, CaseOrderSchema, RemoteCaseLinkSchema, RemoteHostSchema, DockerCaseLinkSchema, DockerHostSchema, DockerExportSchema, DockerImportSchema, DockerQuickCreateSchema, } from '../schemas.js';
14
+ import { exportDockerCase, importDockerBundle, listDockerExports, exportBundleName } from '../../docker-export.js';
12
15
  import { generateClaudeMd } from '../../templates/claude-md.js';
13
16
  import { writeHooksConfig } from '../../hooks-config.js';
14
17
  import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
15
18
  import { SseEvent } from '../sse-events.js';
16
19
  import { dataPath, getDataDir } from '../../config/instance.js';
20
+ import { checkDockerAvailable, checkDockerImagePresent, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, dockerContainerName, dockerDisplayPath, readDockerCases, readDockerHosts, toSessionDocker, writeDockerCases, writeDockerHosts, } from '../../docker-hosts.js';
21
+ import { buildDockerRemoveCommand } from '../../tmux-manager.js';
17
22
  import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, remoteDisplayPath, writeRemoteCases, writeRemoteHosts, } from '../../remote-hosts.js';
18
23
  const LINKED_CASES_FILE = dataPath('linked-cases.json');
19
24
  const CODEMAN_CONFIG_DIR = getDataDir();
20
25
  const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
26
+ const DOCKER_EXPORTS_DIR = dataPath('docker-exports');
27
+ /** Auto-created host profile for the one-click "Run in Docker" case flow. */
28
+ const DEFAULT_DOCKER_HOST_ID = 'default';
29
+ /** App version for export manifests (best-effort read of package.json). */
30
+ const APP_VERSION = (() => {
31
+ try {
32
+ const pkgPath = fileURLToPath(new URL('../../../package.json', import.meta.url));
33
+ return JSON.parse(readFileSync(pkgPath, 'utf-8')).version || 'unknown';
34
+ }
35
+ catch {
36
+ return 'unknown';
37
+ }
38
+ })();
21
39
  /** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
22
40
  async function readLinkedCases() {
23
41
  return readJsonConfig(LINKED_CASES_FILE, 'linked cases', {});
@@ -29,6 +47,40 @@ async function resolveCasePath(name) {
29
47
  return linkedCases[name];
30
48
  return join(CASES_DIR, name);
31
49
  }
50
+ /**
51
+ * Gate a docker case on its base image, AUTO-BUILDING the default image on first
52
+ * use so a missing image is never a blocker (the user's ask: "create it when it's
53
+ * used for the first time"). Present image → verify tmux (hard prerequisite).
54
+ * Default image missing → kick off a BACKGROUND build with SSE progress and return
55
+ * `imageBuilding: true` (the case is created regardless; first launch awaits the
56
+ * same dedup'd build). Custom image missing → a real error (we can't build a
57
+ * foreign ref, and `--pull=never` forbids pulling).
58
+ */
59
+ async function ensureCaseImage(broadcast, sessionDocker, name) {
60
+ if (await checkDockerImagePresent(sessionDocker.engine, sessionDocker.image)) {
61
+ const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
62
+ if (!tmuxCheck.ok)
63
+ return { ok: false, error: tmuxCheck.error || 'base image is missing tmux' };
64
+ return { ok: true, imageBuilding: false };
65
+ }
66
+ if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
67
+ return {
68
+ ok: false,
69
+ error: `base image ${sessionDocker.image} not present; only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull it first.`,
70
+ };
71
+ }
72
+ broadcast(SseEvent.DockerImageBuildStarted, { name, image: sessionDocker.image });
73
+ void ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
74
+ onProgress: (line) => broadcast(SseEvent.DockerImageBuildProgress, { name, line }),
75
+ })
76
+ .then((r) => broadcast(r.ok ? SseEvent.DockerImageBuildComplete : SseEvent.DockerImageBuildFailed, {
77
+ name,
78
+ image: sessionDocker.image,
79
+ error: r.error,
80
+ }))
81
+ .catch((err) => broadcast(SseEvent.DockerImageBuildFailed, { name, image: sessionDocker.image, error: getErrorMessage(err) }));
82
+ return { ok: true, imageBuilding: true };
83
+ }
32
84
  export function registerCaseRoutes(app, ctx) {
33
85
  // ═══════════════════════════════════════════════════════════════
34
86
  // Case CRUD (list, create, link, detail, fix-plan)
@@ -95,6 +147,36 @@ export function registerCaseRoutes(app, ctx) {
95
147
  cases[existingIndex] = remoteCaseInfo;
96
148
  }
97
149
  }
150
+ // Get docker cases
151
+ const dockerHosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
152
+ const dockerHostMap = new Map(dockerHosts.map((host) => [host.id, host]));
153
+ for (const dockerCase of await readDockerCases(CODEMAN_CONFIG_DIR)) {
154
+ const host = dockerHostMap.get(dockerCase.hostId);
155
+ if (!host || !SAFE_CASE_NAME.test(dockerCase.name))
156
+ continue;
157
+ existingNames.add(dockerCase.name);
158
+ const container = dockerCase.container ?? dockerContainerName(dockerCase.name);
159
+ const dockerCaseInfo = {
160
+ name: dockerCase.name,
161
+ path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
162
+ hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
163
+ location: 'docker',
164
+ docker: {
165
+ hostId: host.id,
166
+ container,
167
+ image: host.image,
168
+ path: dockerCase.hostWorkspacePath,
169
+ network: host.network ?? 'bridge',
170
+ },
171
+ };
172
+ const existingIndex = cases.findIndex((item) => item.name === dockerCase.name);
173
+ if (existingIndex === -1) {
174
+ cases.push(dockerCaseInfo);
175
+ }
176
+ else {
177
+ cases[existingIndex] = dockerCaseInfo;
178
+ }
179
+ }
98
180
  // Sort by persisted caseOrder from settings.json
99
181
  const settings = await readJsonConfig(SETTINGS_PATH, 'settings', {});
100
182
  const caseOrder = Array.isArray(settings.caseOrder) ? settings.caseOrder : [];
@@ -189,6 +271,300 @@ export function registerCaseRoutes(app, ctx) {
189
271
  ctx.broadcast(SseEvent.CaseLinked, { name: remoteCase.name, path: remoteCase.remotePath, type: 'remote' });
190
272
  return { success: true, data: { case: remoteCase } };
191
273
  });
274
+ // ========== Docker hosts + docker cases (COD-Docker) ==========
275
+ app.get('/api/docker-hosts', async () => readDockerHosts(CODEMAN_CONFIG_DIR));
276
+ app.post('/api/docker-hosts', async (req) => {
277
+ const host = parseBody(DockerHostSchema, req.body);
278
+ const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
279
+ if (hosts.some((item) => item.id === host.id)) {
280
+ return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Docker host already exists');
281
+ }
282
+ await writeDockerHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
283
+ return { success: true, data: { host } };
284
+ });
285
+ app.put('/api/docker-hosts/:id', async (req) => {
286
+ const { id } = req.params;
287
+ const host = parseBody(DockerHostSchema, { ...req.body, id });
288
+ const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
289
+ const index = hosts.findIndex((item) => item.id === id);
290
+ if (index === -1)
291
+ return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
292
+ const next = [...hosts];
293
+ next[index] = host;
294
+ await writeDockerHosts(CODEMAN_CONFIG_DIR, next);
295
+ return { success: true, data: { host } };
296
+ });
297
+ app.delete('/api/docker-hosts/:id', async (req) => {
298
+ const { id } = req.params;
299
+ const cases = await readDockerCases(CODEMAN_CONFIG_DIR);
300
+ if (cases.some((item) => item.hostId === id)) {
301
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Docker host is still used by docker cases');
302
+ }
303
+ const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
304
+ await writeDockerHosts(CODEMAN_CONFIG_DIR, hosts.filter((item) => item.id !== id));
305
+ return { success: true, data: { id } };
306
+ });
307
+ app.post('/api/cases/docker-link', async (req) => {
308
+ const dockerCase = { ...parseBody(DockerCaseLinkSchema, req.body), type: 'docker' };
309
+ const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
310
+ const host = hosts.find((item) => item.id === dockerCase.hostId);
311
+ if (!host)
312
+ return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
313
+ const linkedCases = await readLinkedCases();
314
+ const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
315
+ if (dockerCases.some((item) => item.name === dockerCase.name) ||
316
+ linkedCases[dockerCase.name] ||
317
+ existsSync(join(CASES_DIR, dockerCase.name))) {
318
+ return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
319
+ }
320
+ // The workspace is a REAL host directory (bind-mounted into the container), so
321
+ // create it now if missing. Scaffolding (.claude/settings.local.json + CLAUDE.md)
322
+ // is written by quick-start on first launch, matching local-case behaviour.
323
+ if (!existsSync(dockerCase.hostWorkspacePath)) {
324
+ try {
325
+ mkdirSync(dockerCase.hostWorkspacePath, { recursive: true });
326
+ }
327
+ catch (err) {
328
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Could not create workspace: ${getErrorMessage(err)}`);
329
+ }
330
+ }
331
+ // Courtesy validation: docker daemon must be reachable. The base image is
332
+ // auto-built on first use (default image) rather than being a link-time
333
+ // blocker, so a missing image kicks off a background build instead of erroring.
334
+ const availability = await checkDockerAvailable(host.engine);
335
+ if (!availability.ok) {
336
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, availability.error || 'docker daemon is not available');
337
+ }
338
+ const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), dockerCase.name);
339
+ if (!imageGate.ok) {
340
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
341
+ }
342
+ await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]);
343
+ ctx.broadcast(SseEvent.CaseLinked, {
344
+ name: dockerCase.name,
345
+ path: dockerCase.hostWorkspacePath,
346
+ type: 'docker',
347
+ });
348
+ return {
349
+ success: true,
350
+ data: {
351
+ case: dockerCase,
352
+ capsEnforced: availability.capsEnforced,
353
+ isDesktop: availability.isDesktop,
354
+ imageBuilding: imageGate.imageBuilding,
355
+ },
356
+ };
357
+ });
358
+ // One-click "Run in Docker": create a NORMAL case (folder in CASES_DIR, scaffolded)
359
+ // AND link it to a hardened container with default settings, auto-provisioning a
360
+ // shared `default` docker host so the user never touches host/image/network fields.
361
+ app.post('/api/cases/docker-quickcreate', async (req) => {
362
+ const body = parseBody(DockerQuickCreateSchema, req.body);
363
+ const { name, description } = body;
364
+ const casePath = validatePathWithinBase(name, CASES_DIR);
365
+ if (!casePath)
366
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
367
+ // Collision across every case kind.
368
+ const linkedCases = await readLinkedCases();
369
+ const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
370
+ const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
371
+ if (existsSync(casePath) ||
372
+ dockerCases.some((item) => item.name === name) ||
373
+ remoteCases.some((item) => item.name === name) ||
374
+ linkedCases[name]) {
375
+ return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
376
+ }
377
+ // The checkbox alone (no overrides) uses the shared `default` host; any tweaked
378
+ // setting gets a dedicated per-case host so it never mutates the shared default.
379
+ const hasOverrides = !!(body.image ||
380
+ body.network ||
381
+ body.networkName ||
382
+ body.memory ||
383
+ body.cpus ||
384
+ body.gpus ||
385
+ body.mountCredentials !== undefined);
386
+ const resources = {};
387
+ if (body.memory)
388
+ resources.memory = body.memory;
389
+ if (body.cpus)
390
+ resources.cpus = body.cpus;
391
+ const desiredHost = {
392
+ id: hasOverrides ? `q-${name}` : DEFAULT_DOCKER_HOST_ID,
393
+ label: hasOverrides ? `Case: ${name}` : 'Default',
394
+ image: body.image || DEFAULT_AGENT_IMAGE,
395
+ network: body.network || 'bridge',
396
+ ...(body.networkName ? { networkName: body.networkName } : {}),
397
+ ...(Object.keys(resources).length ? { resources } : {}),
398
+ ...(body.gpus ? { gpus: body.gpus } : {}),
399
+ mountCredentials: body.mountCredentials ?? true,
400
+ resumeOnStart: true,
401
+ hooksEnabled: true,
402
+ };
403
+ const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
404
+ const existing = hosts.find((item) => item.id === desiredHost.id);
405
+ // Reuse the shared default if present; create/refresh a per-case host for overrides.
406
+ const host = existing && !hasOverrides ? existing : desiredHost;
407
+ if (!existing) {
408
+ await writeDockerHosts(CODEMAN_CONFIG_DIR, [...hosts, desiredHost]);
409
+ }
410
+ else if (hasOverrides) {
411
+ await writeDockerHosts(CODEMAN_CONFIG_DIR, hosts.map((h) => (h.id === desiredHost.id ? desiredHost : h)));
412
+ }
413
+ // Probe the daemon BEFORE scaffolding so a missing docker surfaces a clear
414
+ // error instead of leaving an orphaned case folder. The base image is NOT a
415
+ // blocker: a missing default image auto-builds in the background on first use.
416
+ const availability = await checkDockerAvailable(host.engine);
417
+ if (!availability.ok) {
418
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, availability.error || 'docker daemon is not available');
419
+ }
420
+ const dockerCase = { name, type: 'docker', hostId: host.id, hostWorkspacePath: casePath };
421
+ const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), name);
422
+ if (!imageGate.ok) {
423
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
424
+ }
425
+ // Scaffold the case folder exactly like a normal case.
426
+ try {
427
+ mkdirSync(casePath, { recursive: true });
428
+ mkdirSync(join(casePath, 'src'), { recursive: true });
429
+ const templatePath = await ctx.getDefaultClaudeMdPath();
430
+ writeFileSync(join(casePath, 'CLAUDE.md'), generateClaudeMd(name, description || '', templatePath));
431
+ await writeHooksConfig(casePath);
432
+ }
433
+ catch (err) {
434
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
435
+ }
436
+ await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]);
437
+ ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
438
+ ctx.broadcast(SseEvent.CaseLinked, { name, path: casePath, type: 'docker' });
439
+ return {
440
+ success: true,
441
+ data: {
442
+ case: dockerCase,
443
+ capsEnforced: availability.capsEnforced,
444
+ isDesktop: availability.isDesktop,
445
+ imageBuilding: imageGate.imageBuilding,
446
+ },
447
+ };
448
+ });
449
+ // ========== Docker export / import ==========
450
+ // Export a docker case to a portable bundle. Runs in the BACKGROUND (a full image
451
+ // save can take minutes) and broadcasts docker:exportComplete / docker:exportFailed.
452
+ app.post('/api/docker-cases/:name/export', async (req) => {
453
+ const { name } = req.params;
454
+ const { mode = 'full' } = parseBody(DockerExportSchema, req.body ?? {});
455
+ const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
456
+ if (!dockerCase)
457
+ return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found');
458
+ const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
459
+ if (!host)
460
+ return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
461
+ const sessionDocker = toSessionDocker(host, dockerCase);
462
+ if (mode === 'full' && !sessionDocker.mountCredentials) {
463
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'full-image export is refused for a sealed container (its in-container login would ride the committed layer). Use a workspace-only export.');
464
+ }
465
+ const timestamp = Date.now();
466
+ const bundle = exportBundleName(name, timestamp, mode);
467
+ // Fire-and-forget: the client watches for the SSE completion event.
468
+ void exportDockerCase({
469
+ docker: sessionDocker,
470
+ caseName: name,
471
+ timestamp,
472
+ exportsDir: DOCKER_EXPORTS_DIR,
473
+ mode,
474
+ codemanVersion: APP_VERSION,
475
+ })
476
+ .then((result) => {
477
+ ctx.broadcast(SseEvent.DockerExportComplete, {
478
+ name,
479
+ bundle: basename(result.bundlePath),
480
+ sizeBytes: result.sizeBytes,
481
+ mode,
482
+ });
483
+ })
484
+ .catch((err) => {
485
+ ctx.broadcast(SseEvent.DockerExportFailed, { name, mode, error: getErrorMessage(err) });
486
+ });
487
+ return { success: true, data: { started: true, bundle } };
488
+ });
489
+ app.get('/api/docker-exports', async () => {
490
+ return { success: true, data: { exports: await listDockerExports(DOCKER_EXPORTS_DIR) } };
491
+ });
492
+ // Download an export bundle (filename resolved WITHIN the exports dir — no traversal).
493
+ app.get('/api/docker-exports/:filename', async (req, reply) => {
494
+ const { filename } = req.params;
495
+ if (!/^[a-zA-Z0-9._-]+\.tgz$/.test(filename)) {
496
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid bundle filename');
497
+ }
498
+ const full = join(DOCKER_EXPORTS_DIR, filename);
499
+ if (!existsSync(full))
500
+ return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Export not found');
501
+ reply.header('Content-Type', 'application/gzip');
502
+ reply.header('Content-Disposition', `attachment; filename="${filename}"`);
503
+ reply.header('X-Content-Type-Options', 'nosniff');
504
+ return reply.send(createReadStream(full));
505
+ });
506
+ app.delete('/api/docker-exports/:filename', async (req) => {
507
+ const { filename } = req.params;
508
+ if (!/^[a-zA-Z0-9._-]+\.tgz$/.test(filename)) {
509
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid bundle filename');
510
+ }
511
+ const full = join(DOCKER_EXPORTS_DIR, filename);
512
+ if (!existsSync(full))
513
+ return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Export not found');
514
+ await fs.rm(full, { force: true });
515
+ return { success: true, data: { filename } };
516
+ });
517
+ // Import a bundle (already present in the exports dir) into a NEW docker case.
518
+ app.post('/api/docker-cases/import', async (req) => {
519
+ const { bundle, newCaseName, destWorkspacePath } = parseBody(DockerImportSchema, req.body);
520
+ const bundlePath = join(DOCKER_EXPORTS_DIR, bundle);
521
+ if (!existsSync(bundlePath))
522
+ return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Bundle not found in exports dir');
523
+ // Name-collision guard across ALL case kinds.
524
+ const linkedCases = await readLinkedCases();
525
+ const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
526
+ if (dockerCases.some((item) => item.name === newCaseName) ||
527
+ linkedCases[newCaseName] ||
528
+ existsSync(join(CASES_DIR, newCaseName))) {
529
+ return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
530
+ }
531
+ const timestamp = Date.now();
532
+ let result;
533
+ try {
534
+ result = await importDockerBundle({ bundlePath, destWorkspace: destWorkspacePath, engine: 'docker', timestamp });
535
+ }
536
+ catch (err) {
537
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Import failed: ${getErrorMessage(err)}`);
538
+ }
539
+ // Create a dedicated docker host pointing at the quarantined imported image
540
+ // (full mode) or the manifest's base image (workspace-only).
541
+ const hostId = `imported-${newCaseName}`;
542
+ const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
543
+ if (!hosts.some((h) => h.id === hostId)) {
544
+ await writeDockerHosts(CODEMAN_CONFIG_DIR, [
545
+ ...hosts,
546
+ {
547
+ id: hostId,
548
+ label: `Imported: ${newCaseName}`,
549
+ engine: result.manifest.engine,
550
+ image: result.importedImage ?? result.manifest.image,
551
+ network: (['bridge', 'none', 'custom'].includes(result.manifest.network)
552
+ ? result.manifest.network
553
+ : 'bridge'),
554
+ },
555
+ ]);
556
+ }
557
+ const newCase = {
558
+ name: newCaseName,
559
+ type: 'docker',
560
+ hostId,
561
+ hostWorkspacePath: destWorkspacePath,
562
+ containerWorkdir: result.manifest.containerWorkdir,
563
+ };
564
+ await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, newCase]);
565
+ ctx.broadcast(SseEvent.DockerImportComplete, { name: newCaseName, path: destWorkspacePath, type: 'docker' });
566
+ return { success: true, data: { case: newCase } };
567
+ });
192
568
  // Link an existing folder as a case
193
569
  app.post('/api/cases/link', async (req) => {
194
570
  const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
@@ -236,6 +612,29 @@ export function registerCaseRoutes(app, ctx) {
236
612
  ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'remote-unlinked' });
237
613
  return { success: true, data: { name } };
238
614
  }
615
+ const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
616
+ const dockerCase = dockerCases.find((item) => item.name === name);
617
+ if (dockerCase) {
618
+ await writeDockerCases(CODEMAN_CONFIG_DIR, dockerCases.filter((item) => item.name !== name));
619
+ // Best-effort `docker rm -f` the per-case container (case-delete is the
620
+ // explicit teardown that removes it; the bind-mounted workspace survives).
621
+ const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
622
+ if (host) {
623
+ const sessionDocker = toSessionDocker(host, dockerCase);
624
+ try {
625
+ exec(buildDockerRemoveCommand(sessionDocker), { timeout: 15_000 }, () => { });
626
+ }
627
+ catch {
628
+ /* best-effort — never blocks the unlink */
629
+ }
630
+ // Remove the per-container claude-config seed file (account metadata copy).
631
+ await fs
632
+ .rm(join(dataPath('docker-seeds'), `${sessionDocker.containerName}.json`), { force: true })
633
+ .catch(() => { });
634
+ }
635
+ ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'docker-unlinked' });
636
+ return { success: true, data: { name } };
637
+ }
239
638
  // Check linked cases first — unlink only, don't delete the actual directory
240
639
  const linkedCases = await readLinkedCases();
241
640
  if (linkedCases[name]) {
@@ -311,6 +710,26 @@ export function registerCaseRoutes(app, ctx) {
311
710
  },
312
711
  };
313
712
  }
713
+ const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
714
+ if (dockerCase) {
715
+ const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
716
+ if (!host)
717
+ return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
718
+ const container = dockerCase.container ?? dockerContainerName(dockerCase.name);
719
+ return {
720
+ name,
721
+ path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
722
+ hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
723
+ location: 'docker',
724
+ docker: {
725
+ hostId: host.id,
726
+ container,
727
+ image: host.image,
728
+ path: dockerCase.hostWorkspacePath,
729
+ network: host.network ?? 'bridge',
730
+ },
731
+ };
732
+ }
314
733
  const casePath = await resolveCasePath(name);
315
734
  if (!existsSync(casePath)) {
316
735
  return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');