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
@@ -27,10 +27,12 @@ const execAsync = promisify(exec);
27
27
  import { existsSync, readFileSync, mkdirSync } from 'node:fs';
28
28
  import { writeFile, rename } from 'node:fs/promises';
29
29
  import { dirname } from 'node:path';
30
- import { dataPath, DEFAULT_TMUX_SOCKET } from './config/instance.js';
30
+ import { homedir } from 'node:os';
31
+ import { dataPath, DEFAULT_TMUX_SOCKET, CODEMAN_INSTANCE } from './config/instance.js';
31
32
  import { getErrorMessage, DEFAULT_NICE_CONFIG, } from './types.js';
32
33
  import { buildEffortCliArgs } from './session-cli-builder.js';
33
34
  import { buildSshConnectionArgs, defaultRemoteCommandForMode, remoteSshTarget } from './remote-hosts.js';
35
+ import { buildDockerBaseArgs, buildDockerCreateArgs, containerApiUrl, CONTAINER_HOME, defaultDockerCommandForMode, hostGatewayAlias, resolveDockerClaudeArtifacts, resolveDockerCredentialArtifacts, } from './docker-hosts.js';
34
36
  import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir, resolveCodexDir, resolveGeminiDir, } from './utils/index.js';
35
37
  // ============================================================================
36
38
  // Timing Constants
@@ -700,6 +702,222 @@ export function buildRemoteKillCommand(options) {
700
702
  const [ssh, ...connectionArgs] = buildSshConnectionArgs(remote);
701
703
  return [ssh, ...connectionArgs, remoteSshTarget(remote), shellescape(killCmd)].join(' ');
702
704
  }
705
+ // ========== Docker cases (COD-Docker) ==========
706
+ //
707
+ // The docker analog of the remote-SSH launch above. Instead of a local tmux pane
708
+ // running `ssh -t host 'tmux new-session …'`, it runs `docker exec -it <container>
709
+ // sh -lc 'tmux new-session …'` into a DURABLE in-container tmux server. The
710
+ // container is per-CASE, so many sessions `docker exec` into the same one. See
711
+ // docs/docker-cases-plan.md.
712
+ /**
713
+ * DEDICATED in-container tmux socket. A Codeman running INSIDE the container uses
714
+ * `-L codeman`; ours is `-L codeman-docker` with a `codeman-dkr-*` session name
715
+ * that deliberately FAILS SAFE_MUX_NAME_PATTERN, so an in-container Codeman never
716
+ * adopts/resizes/respawns our session (same defence as the remote socket).
717
+ */
718
+ const DOCKER_TMUX_SOCKET = 'codeman-docker';
719
+ /**
720
+ * Deterministic, reattach-stable in-container tmux session name. Derived from the
721
+ * same stable field the local muxName uses (first 8 chars of the sessionId), so a
722
+ * reconnect re-issues the exact same `new-session -A` and lands back in the SAME
723
+ * in-container session. The `dkr` letters make it fail SAFE_MUX_NAME_PATTERN.
724
+ */
725
+ export function dockerTmuxSessionName(sessionId) {
726
+ return `codeman-dkr-${sessionId.slice(0, 8)}`;
727
+ }
728
+ /** Resume ids are UUID-ish; reject anything with shell metacharacters (defensive). */
729
+ const RESUME_ID_SAFE = /^[A-Za-z0-9._-]+$/;
730
+ /**
731
+ * Append the CLI-specific resume flag to a pane command. Only fires when the
732
+ * in-container tmux is RE-CREATED (`new-session -A` makes the flag inert on a
733
+ * live reattach), i.e. exactly when the previous live agent was lost and we want
734
+ * to resume the conversation from the bind-mounted transcript.
735
+ */
736
+ function appendResumeFlag(modeCommand, mode, resumeId) {
737
+ if (!RESUME_ID_SAFE.test(resumeId))
738
+ return modeCommand;
739
+ switch (mode) {
740
+ case 'claude':
741
+ case 'gemini':
742
+ return `${modeCommand} --resume ${resumeId}`;
743
+ case 'codex':
744
+ return `${modeCommand} resume ${resumeId}`;
745
+ default:
746
+ return modeCommand; // shell / opencode: no resume
747
+ }
748
+ }
749
+ /**
750
+ * Build the ONE `bash -c` launch string for a docker session: image-check ->
751
+ * ensure (inspect-or-create) -> start -> `exec docker exec -it` into the durable
752
+ * in-container tmux (resume-aware). PURE and unit-testable. The escaping survives
753
+ * four layers: outer `bash -c "…"` (JSON.stringify at respawn-pane) -> the joined
754
+ * command -> `docker exec … sh -lc '<tmux>'` -> tmux `'<paneCommand>'`.
755
+ */
756
+ export function buildDockerLaunchCommand(opts) {
757
+ const { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies } = opts;
758
+ const base = buildDockerBaseArgs(docker).join(' ');
759
+ const createArgs = buildDockerCreateArgs(createContext).join(' ');
760
+ const name = shellescape(docker.containerName);
761
+ const workdir = shellescape(docker.containerWorkdir);
762
+ const image = shellescape(docker.image);
763
+ const dkrName = dockerTmuxSessionName(sessionId);
764
+ const sid = sessionId.slice(0, 8);
765
+ let modeCommand = docker.commands?.[mode] || defaultDockerCommandForMode(mode);
766
+ if (resumeSessionId)
767
+ modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
768
+ // Run by tmux via /bin/sh -c, so the path is shell-quoted here. `exec` makes the
769
+ // pane PID the agent itself.
770
+ const paneCommand = `cd ${workdir} && ${modeCommand}`;
771
+ // `setenv -g` primes the session id so reattaches / newly-created panes inherit
772
+ // it. `new-session -A` = attach-or-create (idempotent + resume-aware). Options
773
+ // are scoped per-session (`set -t`) or server (`set -s`), never `-g`, so a shared
774
+ // in-container tmux server's other sessions keep their own prefix/mouse.
775
+ const tmuxInvocation = [
776
+ `tmux -L ${DOCKER_TMUX_SOCKET} setenv -g CODEMAN_SESSION_ID ${shellescape(sid)}`,
777
+ 'setenv -g CODEMAN_MUX 1',
778
+ `new-session -A -s ${dkrName} -c ${workdir} ${shellescape(paneCommand)}`,
779
+ `set -t ${dkrName} status off`,
780
+ `set -t ${dkrName} mouse off`,
781
+ `set -t ${dkrName} prefix C-q`,
782
+ 'set -s escape-time 0',
783
+ ].join(' \\; ');
784
+ const execEnvFlags = [];
785
+ for (const [k, v] of Object.entries(execEnv))
786
+ execEnvFlags.push('--env', shellescape(`${k}=${v}`));
787
+ // NAME-ONLY forwards: docker reads the VALUE from Codeman's own process env, so
788
+ // the secret never appears in argv (no `ps` leak) and is not committed.
789
+ for (const n of execEnvNames)
790
+ execEnvFlags.push('--env', n);
791
+ for (const extra of docker.extraExecArgs ?? [])
792
+ execEnvFlags.push(shellescape(extra));
793
+ const imageMissingMsg = shellescape(`Codeman: base image ${docker.image} not present (it is normally auto-built on first use)`);
794
+ const startFailMsg = shellescape(`Codeman: container ${docker.containerName} failed to start (docker daemon down?)`);
795
+ const imageCheck = `${base} image inspect ${image} >/dev/null 2>&1 || { echo ${imageMissingMsg}; exit 1; }`;
796
+ // create-if-missing (idempotent): reconnect / boot recovery re-runs this exact chain.
797
+ const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`;
798
+ const start = `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`;
799
+ // Seed writable credential config from read-only host mounts ONCE per container
800
+ // (guarded by [ -e ] so reconnects never clobber in-container config; `cp -a` for
801
+ // whole-dir credential seeds). mkdir -p the parent so a file seed works even when
802
+ // no sibling share-mount pre-created the dir. Paths are fixed CONTAINER_HOME
803
+ // constants (no shell metachars), so the whole inner command is shell-quoted once.
804
+ const seedSteps = (seedCopies ?? []).map((s) => {
805
+ const cp = s.recursive ? 'cp -a' : 'cp';
806
+ const parent = s.to.slice(0, s.to.lastIndexOf('/'));
807
+ return `mkdir -p ${parent} 2>/dev/null; [ -e ${s.to} ] || ${cp} ${s.from} ${s.to} 2>/dev/null || true`;
808
+ });
809
+ const innerCmd = seedSteps.length ? `${seedSteps.join(' ; ')} ; ${tmuxInvocation}` : tmuxInvocation;
810
+ const execCmd = `exec ${base} exec -it --workdir ${workdir} ${execEnvFlags.join(' ')} ${name} sh -lc ${shellescape(innerCmd)}`;
811
+ return [imageCheck, ensure, start, execCmd].join(' ; ');
812
+ }
813
+ /**
814
+ * Kill ONLY this session's in-container tmux session. The container is shared by
815
+ * the case's other sessions, so this NEVER `docker stop`s it — stopping/removing
816
+ * the container is an explicit teardown (buildDockerStopCommand) or case-delete
817
+ * (buildDockerRemoveCommand). Fired best-effort on session kill.
818
+ */
819
+ export function buildDockerKillCommand(options) {
820
+ const { docker, sessionId } = options;
821
+ const base = buildDockerBaseArgs(docker).join(' ');
822
+ const dkrName = dockerTmuxSessionName(sessionId);
823
+ return `${base} exec ${shellescape(docker.containerName)} tmux -L ${DOCKER_TMUX_SOCKET} kill-session -t ${shellescape(dkrName)}`;
824
+ }
825
+ /** Explicit container stop (frees RAM/CPU; conversation resumes on next launch via --resume). */
826
+ export function buildDockerStopCommand(docker) {
827
+ return `${buildDockerBaseArgs(docker).join(' ')} stop -t 10 ${shellescape(docker.containerName)}`;
828
+ }
829
+ /** Explicit container removal (case-delete). Destroys in-image state; bind mounts survive. */
830
+ export function buildDockerRemoveCommand(docker) {
831
+ return `${buildDockerBaseArgs(docker).join(' ')} rm -f ${shellescape(docker.containerName)}`;
832
+ }
833
+ /**
834
+ * Resolve the environment-dependent bits of a docker launch (host uid, existing
835
+ * credential mounts, derived api url, hook-secret mount, Desktop detection) into
836
+ * the pure buildDockerLaunchCommand inputs. IO; only ever called from the real
837
+ * launch path (createSession/respawnPane no-op under VITEST).
838
+ */
839
+ export function resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId) {
840
+ const home = homedir();
841
+ const isDesktop = process.platform === 'darwin'; // Docker Desktop translates uids + native host.docker.internal
842
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 1000;
843
+ const userArgs = docker.engine === 'podman'
844
+ ? ['--userns=keep-id'] // rootless podman: map host uid to the image `agent` uid
845
+ : isDesktop
846
+ ? [] // Desktop: run as the image's baked uid (a mac uid wouldn't own /home/agent)
847
+ : ['--user', `${uid}:0`]; // Linux: host uid + GID 0 (OpenShift arbitrary-uid writable HOME)
848
+ const gatewayAlias = hostGatewayAlias(docker.engine);
849
+ const credentialMounts = [];
850
+ const extraMounts = [];
851
+ // Isolated credential state (Claude + codex/gemini/gcloud/opencode): each store
852
+ // shares ONLY what a host feature / --resume needs (Claude projects/, codex
853
+ // sessions/+history) and seeds everything else (tokens, settings, configs) as
854
+ // writable copies, so the container is authed WITHOUT re-auth and WITHOUT writing
855
+ // its runtime state back into the host dirs. Only when credentials are mounted.
856
+ let seedCopies = [];
857
+ if (docker.mountCredentials) {
858
+ const claudeArtifacts = resolveDockerClaudeArtifacts(home, docker.containerName, docker.containerWorkdir);
859
+ const credArtifacts = resolveDockerCredentialArtifacts(home);
860
+ extraMounts.push(...claudeArtifacts.mounts, ...credArtifacts.mounts);
861
+ seedCopies = [...claudeArtifacts.seedCopies, ...credArtifacts.seedCopies];
862
+ }
863
+ const envCreate = {
864
+ HOME: CONTAINER_HOME,
865
+ TERM: 'xterm-256color',
866
+ COLORTERM: 'truecolor',
867
+ // Force a UTF-8 locale (the base image defaults to POSIX/C). Without this, tmux
868
+ // runs in non-UTF-8 mode and renders Claude's Unicode box-drawing (─│┌┐) as raw
869
+ // VT100 ACS glyphs (`qqqq…`). `C.UTF-8` is built into glibc (no locale-gen).
870
+ LANG: 'C.UTF-8',
871
+ LC_ALL: 'C.UTF-8',
872
+ // Give claude a temp dir it will own inside HOME. Its default `/tmp/claude-<uid>`
873
+ // is refused when that path pre-exists root-owned — which happens when the
874
+ // workspace bind-mount path traverses it (e.g. a workspace under /tmp/claude-<uid>).
875
+ // A nonexistent HOME subpath is created+owned by the running uid, so this is robust
876
+ // to any workspace location. Non-secret path, safe to be committed on export.
877
+ CLAUDE_CODE_TMPDIR: `${CONTAINER_HOME}/.cache/codeman-claude-tmp`,
878
+ };
879
+ if (docker.hooksEnabled) {
880
+ // Derive a container-reachable API url (scheme + port preserved; host swapped
881
+ // for the engine gateway alias). Prod is HTTPS on 3000.
882
+ envCreate.CODEMAN_API_URL = containerApiUrl(process.env.CODEMAN_API_URL, docker.engine);
883
+ const hookSecretPath = dataPath('hook-secret');
884
+ if (existsSync(hookSecretPath)) {
885
+ const dst = `${CONTAINER_HOME}/.codeman/hook-secret`;
886
+ extraMounts.push({ src: hookSecretPath, dst, readonly: true });
887
+ envCreate.CODEMAN_HOOK_SECRET_FILE = dst; // a path is non-secret; the bytes ride the bind mount
888
+ }
889
+ }
890
+ const createContext = {
891
+ docker,
892
+ sessionId,
893
+ instance: CODEMAN_INSTANCE,
894
+ userArgs,
895
+ credentialMounts,
896
+ extraMounts,
897
+ envCreate,
898
+ addHostGateway: !isDesktop,
899
+ gatewayAlias,
900
+ };
901
+ const execEnv = {
902
+ TERM: 'xterm-256color',
903
+ COLORTERM: 'truecolor',
904
+ // UTF-8 at exec time too, so the tmux CLIENT this exec launches is UTF-8 and
905
+ // renders box-drawing correctly even when reattaching to a container created
906
+ // before this fix (client_utf8 is per-client, resolved from the exec's locale).
907
+ LANG: 'C.UTF-8',
908
+ LC_ALL: 'C.UTF-8',
909
+ CODEMAN_SESSION_ID: sessionId.slice(0, 8),
910
+ CODEMAN_MUX: '1',
911
+ };
912
+ // NAME-ONLY exec env forwarded from Codeman's process env (the docker client
913
+ // inherits it), so API-key CLIs get their key without it appearing in argv.
914
+ const execEnvNames = mode === 'codex'
915
+ ? ['OPENAI_API_KEY', 'CODEX_API_KEY']
916
+ : mode === 'gemini'
917
+ ? ['GEMINI_API_KEY', 'GOOGLE_API_KEY']
918
+ : [];
919
+ return { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies };
920
+ }
703
921
  /**
704
922
  * Set sensitive environment variables on a tmux session via setenv.
705
923
  * These are inherited by panes but not visible in ps output or tmux history.
@@ -1075,7 +1293,7 @@ export class TmuxManager extends EventEmitter {
1075
1293
  * In test mode: creates an in-memory session only (no real tmux session).
1076
1294
  */
1077
1295
  async createSession(options) {
1078
- const { sessionId, workingDir, mode, name, niceConfig, model, claudeMode, allowedTools, openCodeConfig, codexConfig, geminiConfig, resumeSessionId, envOverrides, effort, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, } = options;
1296
+ const { sessionId, workingDir, mode, name, niceConfig, model, claudeMode, allowedTools, openCodeConfig, codexConfig, geminiConfig, resumeSessionId, envOverrides, effort, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, docker, } = options;
1079
1297
  const muxName = `codeman-${sessionId.slice(0, 8)}`;
1080
1298
  if (!isValidMuxName(muxName)) {
1081
1299
  throw new Error('Invalid session name: contains unsafe characters');
@@ -1092,6 +1310,7 @@ export class TmuxManager extends EventEmitter {
1092
1310
  createdAt: Date.now(),
1093
1311
  workingDir,
1094
1312
  remote,
1313
+ docker,
1095
1314
  mode,
1096
1315
  attached: false,
1097
1316
  name,
@@ -1132,7 +1351,11 @@ export class TmuxManager extends EventEmitter {
1132
1351
  try {
1133
1352
  // Build the full command to run inside tmux
1134
1353
  const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
1135
- const fullCmd = remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd;
1354
+ const fullCmd = docker
1355
+ ? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
1356
+ : remote
1357
+ ? buildRemoteLaunchCommand({ mode, remote, sessionId })
1358
+ : localFullCmd;
1136
1359
  // Create tmux session in three steps to handle cold-start (no server running)
1137
1360
  // and avoid the race where the command exits before remain-on-exit is set:
1138
1361
  // 1. Create session with default shell (starts tmux server, stays alive)
@@ -1180,7 +1403,7 @@ export class TmuxManager extends EventEmitter {
1180
1403
  this.applyEnvOverrides(muxName, envOverrides);
1181
1404
  // Replace the shell with the actual command (no echo in terminal). Keep
1182
1405
  // pane launch in /tmp, then cd inside bash against the current mount table.
1183
- const launchCmd = remote ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
1406
+ const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
1184
1407
  execSync(`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`, {
1185
1408
  timeout: EXEC_TIMEOUT_MS,
1186
1409
  stdio: 'ignore',
@@ -1244,6 +1467,7 @@ export class TmuxManager extends EventEmitter {
1244
1467
  createdAt: Date.now(),
1245
1468
  workingDir,
1246
1469
  remote,
1470
+ docker,
1247
1471
  mode,
1248
1472
  attached: false,
1249
1473
  name,
@@ -1311,7 +1535,7 @@ export class TmuxManager extends EventEmitter {
1311
1535
  * preserving the session and its scrollback buffer.
1312
1536
  */
1313
1537
  async respawnPane(options) {
1314
- const { sessionId, workingDir, mode, niceConfig, model, claudeMode, allowedTools, openCodeConfig, codexConfig, geminiConfig, resumeSessionId, envOverrides, effort, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, } = options;
1538
+ const { sessionId, workingDir, mode, niceConfig, model, claudeMode, allowedTools, openCodeConfig, codexConfig, geminiConfig, resumeSessionId, envOverrides, effort, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, docker, } = options;
1315
1539
  const session = this.sessions.get(sessionId);
1316
1540
  if (!session)
1317
1541
  return null;
@@ -1345,7 +1569,11 @@ export class TmuxManager extends EventEmitter {
1345
1569
  const config = niceConfig || DEFAULT_NICE_CONFIG;
1346
1570
  const cmd = wrapWithNice(baseCmd, config);
1347
1571
  const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
1348
- const fullCmd = remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd;
1572
+ const fullCmd = docker
1573
+ ? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
1574
+ : remote
1575
+ ? buildRemoteLaunchCommand({ mode, remote, sessionId })
1576
+ : localFullCmd;
1349
1577
  try {
1350
1578
  // For OpenCode: set sensitive env vars via tmux setenv before respawn
1351
1579
  if (mode === 'opencode') {
@@ -1361,7 +1589,7 @@ export class TmuxManager extends EventEmitter {
1361
1589
  // Re-apply user env overrides before respawn so the new shell inherits them.
1362
1590
  this.applyEnvOverrides(muxName, envOverrides);
1363
1591
  // -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).
1364
- const launchCmd = remote ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
1592
+ const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
1365
1593
  await execAsync(`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`, {
1366
1594
  timeout: EXEC_TIMEOUT_MS,
1367
1595
  });
@@ -1535,6 +1763,18 @@ export class TmuxManager extends EventEmitter {
1535
1763
  // Best-effort — a failure here must not affect the local kill result.
1536
1764
  }
1537
1765
  }
1766
+ // Strategy 3c: Docker sessions run a DURABLE in-container tmux session. Kill
1767
+ // ONLY this session's in-container tmux session (best-effort). The container is
1768
+ // PER-CASE and shared by the case's other sessions, so we deliberately do NOT
1769
+ // `docker stop` it here — stopping/removing is an explicit teardown/case-delete.
1770
+ if (session.docker && !IS_TEST_MODE) {
1771
+ try {
1772
+ exec(buildDockerKillCommand({ docker: session.docker, sessionId }), { timeout: EXEC_TIMEOUT_MS }, () => { });
1773
+ }
1774
+ catch {
1775
+ // Best-effort — never affects the local kill result.
1776
+ }
1777
+ }
1538
1778
  // Strategy 4: Direct kill by PID as final fallback
1539
1779
  if (this.isProcessAlive(currentPid)) {
1540
1780
  try {