mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 18:59:33 +01:00
docs(adopt): document the routes and the overlay, correct stale comments
The two routes are public under /api/v1, so they get an API reference section. The overlay gets an architecture note and a CLAUDE.md rule: an adopted session has session.remote and session.docker undefined and a neutral workingDir, so any new code that reads workingDir as a workspace must also test isAdopted. That is the class of bug this round fixed a dozen times. Records a measured limit: while the view exists an owner's kill-session leaves the window alive under it until the tab closes. Comments that still called workingDir the foreign pane's cwd now say what is true since the wrapper got its neutral folder. Also drops a zh-CN key that duplicated an existing one, and updates the two structure tests pinned on the lines this round changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d62fa10c01
commit
1f6f21fd24
15 files changed
+148
-34
No files matched your search
@@ -175,6 +175,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Docker cases**: a case can point at a **container** running any CLI mode inside it, a **LOCATION OVERLAY on cases, never a `SessionMode`**. One long-lived container **per case**, shared by its sessions: killing a session kills only its in-container tmux, **never** `docker stop` while siblings remain. The workspace is bind-mounted at the **same absolute path**. Credentials are **seeded**, never shared RW. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** A drifted config (label hash) REFUSES the launch. ⚠️ An **adopted** container (`DockerCase.owned === false`) is only `exec`ed into: never create, start, stop, restart, remove, `docker commit` or `docker pause` it; fail closed. Test `owned === false`, never truthiness. ⚠️ Apply `owned` AFTER `dockerConfigHash`. ⚠️ Run modes come from the CONTAINER (`availableModes`), and a failed probe is normal for an OWNED case. ⚠️ Root exec user drops the bypass flag via the registry's `overlays.docker.rootCommand`, never a branch. ⚠️ Adoption is admin-only in multi-user mode. ⚠️ Loopback prod needs `CODEMAN_DOCKER_BRIDGE_HOOKS=1` for in-container hooks. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md`
|
||||
|
||||
**Adopted tmux sessions** (`src/foreign-tmux.ts` pure, `foreign-tmux-discovery.ts`, `foreign-sessions.js`, `POST /api/sessions/adopt`, admin-only in multi-user mode): a tmux session a human started (local, in a container, over ssh) opens as a tab through a wrapper session on OUR socket, a LOCATION OVERLAY like remote and Docker, never a `SessionMode`. ⚠️ `session.remote` and `session.docker` are undefined on one (the connection is on `adopt.*`) and its `workingDir` is the neutral `/tmp`, so anything that treats `workingDir` as the session's workspace, or branches on remote or docker to mean "not here", must also test `session.isAdopted`. ⚠️ `isAdopted` asks who launched the process, so it is never folded into `isExternalCliMode()`. ⚠️ `foreignTargetKey()` is the ONE identity of a target; a socket and a name collide across containers and hosts. ⚠️ Never set anything on the foreign server beyond our own `codeman-view-*`: no `window-size`, no key tables. ⚠️ Session names are someone else's text: they pass the allowlist at discovery, so a browser only ever sends an opaque id. → [architecture-invariants#adopted-foreign-tmux-sessions](docs/architecture-invariants.md#adopted-foreign-tmux-sessions), `docs/api-reference.md`
|
||||
|
||||
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
|
||||
|
||||
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
|
||||
|
||||
@@ -1085,6 +1085,71 @@ the caller's case space, a linked case's exact path, or a Claude worktree of eit
|
||||
session, the create response carries `tabLayout` as it does for `tabGroupId`. Deleting the
|
||||
case forgets the value; existing sessions never move.
|
||||
|
||||
## Adopting a foreign tmux session
|
||||
|
||||
A tmux session someone started by hand (`tmux new -s work`) lives on its own socket,
|
||||
where Codeman does not look for its own sessions. These two routes list such sessions
|
||||
and open one as a tab. Admin-only in multi-user mode (403), since the list shows other
|
||||
people's processes and adopting a shell is arbitrary execution as the server account.
|
||||
|
||||
`GET /api/v1/mux/foreign` lists candidates. Local sessions are always scanned, and
|
||||
cached for 5 s. `?docker=1` and `?remote=1` add the containers behind the saved Docker
|
||||
cases and the saved ssh hosts, one `docker exec` or ssh connection per target, so the
|
||||
browser sends them only on an explicit scan. `?force=1` skips the local cache.
|
||||
|
||||
```json
|
||||
{
|
||||
"sessions": [
|
||||
{
|
||||
"id": "fa9b76d36",
|
||||
"location": "local",
|
||||
"socketPath": "/tmp/tmux-1000/default",
|
||||
"sessionName": "work",
|
||||
"windows": 1,
|
||||
"attached": false,
|
||||
"createdAt": 1793931275000,
|
||||
"mode": "claude",
|
||||
"command": "claude",
|
||||
"workingDir": "/home/me/project",
|
||||
"adoptedBy": "961d0a82-6888-425d-a3ee-dab38ecbb598"
|
||||
}
|
||||
],
|
||||
"scannedAt": 1793931277916,
|
||||
"notes": ["remote Build box: unreachable, or no tmux"],
|
||||
"pollIntervalMs": 8000,
|
||||
"canScanWide": true
|
||||
}
|
||||
```
|
||||
|
||||
`hostId`, `hostLabel` and `containerName` appear on a docker or remote candidate.
|
||||
`adoptedBy` names the session already wrapping that target. `notes` says why a location
|
||||
gave nothing, each opening with where it came from (`local`, `docker <name>`,
|
||||
`remote <label>`). A session whose name or socket path holds characters Codeman will not
|
||||
pass to a shell is skipped and counted in a note. Sessions on a Codeman socket never
|
||||
appear.
|
||||
|
||||
`POST /api/v1/sessions/adopt` wraps one, with `{ "id": "...", "docker": true, "remote":
|
||||
true }` (the two flags must match the listing the id came from; `name` and
|
||||
`parentSessionId` are optional). The body carries only that opaque id: the target is
|
||||
re-resolved by a fresh scan, so a candidate that is gone answers `404`, and one on a
|
||||
location unreachable this time answers `404` saying so.
|
||||
|
||||
The answer is `{ "session": SessionState, "adopted": true }`, or
|
||||
`{ "session": SessionState, "alreadyAdopted": true }` when the target already has a
|
||||
wrapper. A second adopt of a target whose wrapper is still being created is
|
||||
`SESSION_BUSY`. The session has `adopt` set and `pid: null`: start it like any other,
|
||||
`POST .../shell` for `mode: "shell"` and `POST .../interactive` for the rest. `mode`
|
||||
comes from the pane's process tree.
|
||||
|
||||
Closing the session detaches. The foreign session keeps running, and only Codeman's
|
||||
wrapper and its grouped `codeman-view-<id>` session go, so `DELETE` removes nothing that
|
||||
belongs to someone else. Codeman did not start this agent, so these refuse an adopted
|
||||
session: respawn, Ralph, auto-clear, auto-compact, auto-resume, custom model endpoints,
|
||||
prompt uploads and the image watcher. `stop` and `blocked` waits are a 400, the git
|
||||
routes answer `unsupported` with `reason: "adopted"`, and `last-response` answers empty,
|
||||
since Codeman knows no transcript for it. Its `paneExit` stays absent, because the pane
|
||||
holds a `tmux attach` and not the agent. Reboot restore does not rebuild one.
|
||||
|
||||
## Approvals Inbox
|
||||
|
||||
Cross-session queue of prompts waiting on a human (permission dialogs,
|
||||
|
||||
@@ -95,6 +95,20 @@ Further wake-on-LAN detail: the wiring guards in `test/remote-wake.test.ts` are
|
||||
|
||||
Input arriving during a wake is buffered: a chunk over 4 KB is dropped whole, never delivered as a fragment, and the route answers `{buffered:true}` / `{buffered:true, dropped:true}` so the caller can tell. It is flushed in order after `reattachRemote()` with `fromUser`; a flush write that fails drops the rest (logged) rather than retaining it for a wake hours later. Send-and-wait blocks instead and answers `OPERATION_FAILED` when the host never returns. ⚠️ In multi-user mode the attach path 403s a non-admin BEFORE the host is looked up: the wake runs an executable, and remote hosts are admin-only infra everywhere else. ⚠️ Browser keystrokes travel over the WebSocket, which deliberately does NOT pass through the registry (that is the hot path), so only the HTTP input path ever queues anything; the banner must not promise queued input for the Wake button. A request that waits on the wake (create/attach, and the button) uses the 40 s `REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS`, not the 90 s session default, because the dashboard's reverse proxy cuts a request at its own 60 s `proxy_read_timeout`. The `remote:` SSE family is session-scoped in multi-user mode; a create/attach wake names its requester (`username`) since it has no session yet. `remote-wake.ts` refuses real IO under `VITEST` like `remote-files.ts`.
|
||||
|
||||
### Adopted foreign tmux sessions
|
||||
|
||||
**Adopted sessions** (#510; `src/foreign-tmux.ts` pure, `src/foreign-tmux-discovery.ts` transport, `foreign-sessions.js`, `GET /api/mux/foreign`, `POST /api/sessions/adopt`): a tmux session a human started is listed on the home screen, and one click wraps it in a tab. Like remote-SSH and Docker it is a **LOCATION OVERLAY**, never a `SessionMode`: `Session.adopt` carries the connection, the mode comes from a bounded walk of the pane's process tree, and `Session.isAdopted` asks who launched the process, a different question from `isExternalCliMode()`, which is why the two are never folded together.
|
||||
|
||||
**The wrapper.** Adoption creates an ordinary `codeman-<8hex>` session on Codeman's own socket whose pane runs the attach, so `killSession` can only ever reach the wrapper and no shape of caller bug reaches the foreign server. The attach joins as a grouped `codeman-view-<id>` session with `status off` and `destroy-unattached on`. ⚠️ `new-session -t` does not fail on a missing target, it invents one (measured on tmux 3.4: joined `work-prod`, or made a fresh shell, or started a server on an empty socket), so an exact `has-session -t '=<name>'` guards it, and the `=` cannot go on `new-session -t` itself. A `docker exec` outlives its client, so that view is killed explicitly on close. ⚠️ Nothing is ever set on the foreign server beyond our own view: not `window-size` (a window option on a shared window, it survives our detach) and not the prefix table (server-wide, unlike `nestedPrefixTableResetCommands` on the servers Codeman runs itself). ⚠️ While the view exists an owner's `kill-session` leaves the window alive under it until the tab closes.
|
||||
|
||||
**Names are the injection surface.** A session name is chosen by someone else and the local launch chain ends in `bash -c "<JSON>"`, where `$` and backticks stay live. Names and socket paths pass an allowlist at DISCOVERY, so a bad one gets no id and the adopt route, which re-resolves through discovery, can only 404. The ssh probe and attach run in an explicit `sh` behind `withRemoteTmuxPath()`, since the guard chain is POSIX and the login shell may be fish. Docker discovery spawns `dockerEngineArgv()`, never the shell-quoted `buildDockerBaseArgs()`.
|
||||
|
||||
**One wrapper per target.** `foreignTargetKey()` is the ONE key, read by the adopt route and `adoptedBy`, built from the location, the host identity, the socket and the name: a socket and a name alone collide across containers and hosts. Both read live `Session`s, since the mux record only exists after the first attach.
|
||||
|
||||
**No workspace.** ⚠️ An adopted session keeps its connection on `adopt.remote`/`adopt.docker`, so `session.remote` and `session.docker` are undefined, and its `workingDir` is the neutral `/tmp`. Code that branches on those two reads an in-container or over-ssh adoption as a local session working in `/tmp`. So anything that treats `workingDir` as the session's workspace must also test `session.isAdopted` (`adopt` on a pure view): uploads, the upload GC, git status, git fetch and its scheduler, the image watcher, Read My Mind's git signals, the Discord folder, `last-response` and guarded input all do. The Files panel still lists `/tmp`: read-only, and adoption is admin-only in multi-user mode. A wrapper reports `statusLineExporter: 'remote'`, which is also what keeps keep-warm off it.
|
||||
|
||||
**Lifecycle.** `paneExitApplies` is false for a wrapper, so an attach that ended is never `paneExit` and the sweep never closes the tab. Boot recovery re-attaches a dead wrapper, which prints that the session is gone and exits. Reboot restore refuses one, since all Codeman ever had was an attach command.
|
||||
|
||||
### Docker cases
|
||||
|
||||
**Docker cases** (shipped 1.4.0; user guide `docs/docker-cases.md`, design `docs/docker-cases-plan.md`): a case can point at a **container** instead of a local/remote path, and any of the CLI run modes runs INSIDE it. Like remote-SSH, it is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own** (`SessionMode` is unchanged). Storage `~/.codeman/docker-hosts.json` + `docker-cases.json` via `src/docker-hosts.ts` (direct mirror of `remote-hosts.ts`: `readDockerHosts`/`readDockerCases`, `toSessionDocker`, `dockerDisplayPath`, and the PURE builders `buildDockerBaseArgs`/`buildDockerCreateArgs`/`containerApiUrl`/`hostGatewayAlias`/`dockerConfigHash`). CRUD `/api/docker-hosts` + `/api/cases/docker-link`, plus **one-click** `/api/cases/docker-quickcreate` (Create New "Run in Docker" checkbox → case folder in `CASES_DIR` + auto-provisioned shared `default` host + auto-start a session inside; an expandable Template picker Small/Medium/Large/GPU or any override creates a per-case `q-<name>` host), and export/import (`/api/docker-cases/:name/export`, `/api/docker-cases/import`, `GET/DELETE /api/docker-exports`) — all in `case-routes.ts`. Run flows route through `POST /api/quick-start` like remote (session-routes.ts docker branch, skips LOCAL CLI-availability gates). **Launch model**: exactly one long-lived container **per case** (`codeman-case-<slug>`, PID1 `sleep infinity` under `--init`); a LOCAL tmux pane runs `docker exec -it` into a **durable in-container tmux** on dedicated socket `-L codeman-docker`, session `codeman-dkr-<id8>` (deliberately fails `SAFE_MUX_NAME_PATTERN` so a Codeman running INSIDE the container never adopts it, exactly like remote's `codeman-ssh-<id8>`). Builders `buildDockerLaunchCommand`/`buildDockerKillCommand` in `tmux-manager.ts` (image-check → `docker inspect||create` → start → exec, all idempotent). The container is **shared by all sessions of the case**: `buildDockerKillCommand` kills ONLY that session's in-container tmux session, NEVER `docker stop` while siblings remain; `docker rm -f` happens only on case-delete (plus an instance-scoped boot reaper keyed on the `codeman.instance` label). **Two-layer durability/resume** (the central design point): (1) Codeman-PROCESS restart with the container still up → `tmux new-session -A` reattaches the SAME live agent (paneCommand ignored); (2) container stop/reboot/OOM → inner tmux is gone, so the re-run pane command resumes the conversation from the bind-mounted transcript: claude mode pins a DETERMINISTIC conversation id via `claudeDockerPaneCommand()` (`tmux-manager.ts`) — fresh launch `claude --session-id <sessionId> || claude --resume <sessionId>` (a duplicate `--session-id` exits 1 "already in use", so the fallback RESUMES after a container stop; verified CLI behavior), explicit resume `--resume <rid> || --session-id <sid>` so a stale id never dead-panes (leading `exec ` is stripped — an exec'd first branch could never fall back); codex `resume <id>` / gemini `--resume` keep `appendResumeFlag`. The resume id rides `resumeSessionId` through create/respawn options and persists on `DockerCase.lastClaudeSessionId` via `persistDockerCaseClaudeSessionId()` (written at quick-start launch, and again on hook/last-response conversation-id adoption so post-`/clear` switches track; seeded back when `resumeOnStart`, default true); `-A` makes the pane command self-selecting (inert on reattach, active only when tmux was re-created). **Config drift** (`dockerConfigHash` → `codeman.confighash` label): quick-start compares via `checkDockerConfigDrift()` and REFUSES a drifted launch with `CONFLICT`; the UI confirm calls `POST /api/docker-cases/:name/recreate` (refused while case sessions are live) which `docker rm -f`s so the next launch recreates with the new config — host config edits actually take effect. **Workspace** is a REAL host dir bind-mounted at the SAME absolute path (mirror, `dst==src`), so `Session.workingDir = hostWorkspacePath` keeps file-routes/attachments/watchers on real host bytes AND the in-container transcript projHash matches the host so subagent/workflow correlation (and thus resume-id capture) works; `resolveMuxAttachCwd` returns `/tmp` for docker (the local pane only runs `docker exec`). **Creds** arrive commit-safe and ISOLATED (1.4.1; replaced the whole-dir RW mounts that let in-container CLIs write refreshed tokens/state back to the host): shared RW across the boundary is ONLY what host-side reads/resume need (`~/.claude/projects` transcripts; codex `sessions/` + `history.jsonl` for response-viewer/`codex resume`); everything else is SEEDED (RO mount, copied into container HOME once at launch via `[ -e ] || cp`; the container refreshes its own copy and never writes back): `~/.claude.json` is merged through `buildSeamlessClaudeConfig()` (forces `hasCompletedOnboarding` + theme + workspace trust, so no login wizard/theme picker/trust prompt inside the container), plus `.claude/{.credentials.json,setLine truncated
|
||||
|
||||
@@ -304,7 +304,9 @@ export async function discoverRemoteForeign(hosts: RemoteHost[]): Promise<Foreig
|
||||
const { stdout } = await runWithRemoteSshLimit(() => execAsync(foreignRemoteProbeCommand(host), PROBE_OPTS), {
|
||||
maxWaitMs: FOREIGN_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
sessions.push(...candidatesFrom(stdout, 'remote', host.id, { hostId: host.id, hostLabel: host.label }, where, notes));
|
||||
sessions.push(
|
||||
...candidatesFrom(stdout, 'remote', host.id, { hostId: host.id, hostLabel: host.label }, where, notes)
|
||||
);
|
||||
} catch {
|
||||
notes.push(`${where}: unreachable, or no tmux`);
|
||||
}
|
||||
|
||||
+14
-1
@@ -627,6 +627,12 @@ export interface ForeignAttachTarget {
|
||||
* one would rewrite the owner's keys for every session on their server, and it would
|
||||
* outlive our detach. Same reason `window-size` is left alone above. The owner's
|
||||
* bindings stay reachable from a tab, behind our own `send-prefix`, as theirs to use.
|
||||
* - ⚠️ While the view exists the window has two sessions holding it, so an owner's
|
||||
* `kill-session` on their own session leaves the window alive under the view
|
||||
* (measured on tmux 3.4: `tmux ls` then shows only `codeman-view-…`, the shell keeps
|
||||
* running) until the tab closes and `destroy-unattached` lets go of it. Exiting the
|
||||
* shell inside the pane ends it at once, view and all. Nothing here can tell the two
|
||||
* apart without a hook on the owner's server, which is not ours to install.
|
||||
* - Single line: the string crosses `bash -c "..."`, where a real newline would
|
||||
* not survive JSON escaping.
|
||||
*/
|
||||
@@ -746,7 +752,14 @@ export interface ForeignRemoteAttachOptions extends ForeignAttachTarget {
|
||||
*/
|
||||
export function buildForeignRemoteAttachCommand(opts: ForeignRemoteAttachOptions): string {
|
||||
const [ssh, batchMode, ...rest] = opts.sshArgs;
|
||||
return [ssh, batchMode, '-t', ...rest, opts.sshTarget, fshq(foreignRemoteShellCommand(buildForeignTmuxInvocation(opts)))]
|
||||
return [
|
||||
ssh,
|
||||
batchMode,
|
||||
'-t',
|
||||
...rest,
|
||||
opts.sshTarget,
|
||||
fshq(foreignRemoteShellCommand(buildForeignTmuxInvocation(opts))),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
+7
-7
@@ -461,13 +461,13 @@ export function resolveMuxAttachCwd(
|
||||
//
|
||||
// ⚠️ An ADOPTED session needs the same treatment and does NOT get it from the
|
||||
// two checks above: its connection facts live on `adopt.docker`/`adopt.remote`,
|
||||
// so `session.docker` is undefined even for an in-container adoption. Its
|
||||
// `workingDir` is the FOREIGN pane's cwd — a path that routinely does not exist
|
||||
// on this host. Measured against a real container: adopting an in-container
|
||||
// session produced `workingDir: /workspace/pythonserver`, the local PTY spawn
|
||||
// inherited it as its cwd, and the session came up with `pid: null` and a blank
|
||||
// terminal while the wrapper tmux session and the in-container grouped view had
|
||||
// both been created successfully — i.e. it looked half-alive rather than failed.
|
||||
// so `session.docker` is undefined even for an in-container adoption. Its pane
|
||||
// only attaches, so no folder on this host means anything to it. Measured against
|
||||
// a real container, when `workingDir` still held the foreign pane's cwd: the local
|
||||
// PTY spawn inherited `/workspace/pythonserver`, and the session came up with
|
||||
// `pid: null` and a blank terminal while the wrapper tmux session and the
|
||||
// in-container grouped view had both been created successfully, so it looked
|
||||
// half-alive rather than failed.
|
||||
return remote || docker || adopt ? '/tmp' : workingDir;
|
||||
}
|
||||
|
||||
|
||||
+4
-6
@@ -2727,9 +2727,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
// Replace the shell with the actual command (no echo in terminal). Keep
|
||||
// pane launch in /tmp, then cd inside bash against the current mount table.
|
||||
// ⚠️ An adopted pane must NOT be `cd`'d: it only attaches, and its
|
||||
// `workingDir` is the FOREIGN pane's cwd observed at adopt time — a path
|
||||
// that need not exist on this host at all (a container path, a remote path).
|
||||
// ⚠️ An adopted pane is never `cd`'d: it only attaches, and its `workingDir`
|
||||
// is the neutral wrapper folder, not where the foreign pane is.
|
||||
const launchCmd = remote || docker || adopt ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
execSync(
|
||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
||||
@@ -2992,9 +2991,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
this.applyEnvOverrides(muxName, envOverrides, unsetEnvKeys);
|
||||
|
||||
// -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).
|
||||
// ⚠️ An adopted pane must NOT be `cd`'d: it only attaches, and its
|
||||
// `workingDir` is the FOREIGN pane's cwd observed at adopt time — a path
|
||||
// that need not exist on this host at all (a container path, a remote path).
|
||||
// ⚠️ An adopted pane is never `cd`'d: it only attaches, and its `workingDir`
|
||||
// is the neutral wrapper folder, not where the foreign pane is.
|
||||
const launchCmd = remote || docker || adopt ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
await execAsync(
|
||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
||||
|
||||
@@ -378,9 +378,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
rescan.dataset.foreignAction = 'rescan-wide';
|
||||
rescan.disabled = this._foreignWideLoading === true;
|
||||
rescan.title = foreignT('Scan containers and remote hosts again');
|
||||
rescan.textContent = this._foreignWideLoading
|
||||
? foreignT('Scanning…')
|
||||
: foreignT('Rescan');
|
||||
rescan.textContent = this._foreignWideLoading ? foreignT('Scanning…') : foreignT('Rescan');
|
||||
header.appendChild(rescan);
|
||||
}
|
||||
|
||||
|
||||
@@ -1379,13 +1379,12 @@
|
||||
'⟳ all': '⟳ 全部',
|
||||
'⟳ local': '⟳ 本机',
|
||||
'Scanning…': '扫描中…',
|
||||
'Rescan': '重新扫描',
|
||||
'Open': '打开',
|
||||
Rescan: '重新扫描',
|
||||
'Opening…': '正在打开…',
|
||||
'Go to tab': '转到标签页',
|
||||
'this machine': '本机',
|
||||
'container': '容器',
|
||||
'remote': '远程',
|
||||
container: '容器',
|
||||
remote: '远程',
|
||||
'No sessions found outside Codeman.': '未发现在 Codeman 之外打开的会话。',
|
||||
'Could not reach the server.': '无法连接服务器。',
|
||||
'Could not open that session. Refreshing the list.': '无法打开该会话,正在刷新列表。',
|
||||
|
||||
@@ -93,8 +93,7 @@ export function registerReadMyMindRoutes(app: FastifyInstance, ctx: SessionPort
|
||||
// Remote-SSH cases skip git: workingDir is not local. Docker cases are
|
||||
// fine (the workspace is bind-mounted at the same host path). An adopted
|
||||
// session's workingDir is a neutral wrapper folder, so it skips git too.
|
||||
const workspace =
|
||||
session.remote || session.isAdopted ? null : await collectWorkspaceSignals(session.workingDir);
|
||||
const workspace = session.remote || session.isAdopted ? null : await collectWorkspaceSignals(session.workingDir);
|
||||
|
||||
const lastPromptTs = profile.recentPrompts[profile.recentPrompts.length - 1]?.ts;
|
||||
const tracker = ctx.runSummaryTrackers.get(id);
|
||||
|
||||
@@ -33,7 +33,10 @@ const ELSEWHERE = [
|
||||
];
|
||||
const ALL = [...FROM_MODULE, ...LOCATION_WORDS, ...TOGGLE, ...ELSEWHERE];
|
||||
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { runScripts: 'outside-only', url: 'http://localhost/' });
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
runScripts: 'outside-only',
|
||||
url: 'http://localhost/',
|
||||
});
|
||||
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
|
||||
api.configure({ language: 'zh-CN' });
|
||||
@@ -61,8 +64,10 @@ describe('the outside-sessions block in zh-CN', () => {
|
||||
});
|
||||
|
||||
it('adds each key to the dictionary once', () => {
|
||||
// Prettier leaves a key that is one word unquoted.
|
||||
for (const source of new Set(ALL)) {
|
||||
expect(I18N.split(`'${source}':`).length - 1, source).toBe(1);
|
||||
const bare = /^\w+$/.test(source) ? I18N.split(`\n ${source}: `).length - 1 : 0;
|
||||
expect(I18N.split(`'${source}':`).length - 1 + bare, source).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -95,7 +95,11 @@ describe('the wide scan is one-shot', () => {
|
||||
calls.push(path);
|
||||
return ok(
|
||||
path.includes('docker=1')
|
||||
? { sessions: [LOCAL, DOCKER], notes: ['docker box: not running, no tmux, or engine unreachable'], canScanWide: true }
|
||||
? {
|
||||
sessions: [LOCAL, DOCKER],
|
||||
notes: ['docker box: not running, no tmux, or engine unreachable'],
|
||||
canScanWide: true,
|
||||
}
|
||||
: { sessions: [LOCAL], notes: ['local: 1 session(s) skipped'], canScanWide: true }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -483,7 +483,11 @@ describe('the docker and ssh transports', () => {
|
||||
|
||||
it('goes through the saved connection for an ssh host', () => {
|
||||
const cmd = buildAdoptSessionCommand(
|
||||
{ ...adopt, location: 'remote', remote: { hostId: 'h1', label: 'b', host: 'h.example', username: 'me', port: 2222 } },
|
||||
{
|
||||
...adopt,
|
||||
location: 'remote',
|
||||
remote: { hostId: 'h1', label: 'b', host: 'h.example', username: 'me', port: 2222 },
|
||||
},
|
||||
'abc12345'
|
||||
);
|
||||
expect(cmd.startsWith('ssh -o BatchMode=yes -t ')).toBe(true);
|
||||
@@ -506,7 +510,9 @@ describe('the docker and ssh transports', () => {
|
||||
mkdirSync(bin);
|
||||
// The real thing hands its LAST argument to the remote login shell.
|
||||
writeFileSync(join(bin, 'ssh'), '#!/bin/sh\nfor last; do :; done\nexec sh -c "$last"\n', { mode: 0o755 });
|
||||
writeFileSync(join(bin, 'tmux'), '#!/bin/sh\nfor a; do printf "<%s>" "$a"; done\nprintf "\\n"\n', { mode: 0o755 });
|
||||
writeFileSync(join(bin, 'tmux'), '#!/bin/sh\nfor a; do printf "<%s>" "$a"; done\nprintf "\\n"\n', {
|
||||
mode: 0o755,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -450,7 +450,10 @@ describe('an adopted session has no workspace of ours', () => {
|
||||
expect(status.json()).toMatchObject({ success: true, data: { state: 'unsupported', reason: 'adopted' } });
|
||||
const fetch = await app.inject({ method: 'POST', url: `/api/sessions/${ctx._sessionId}/git-fetch` });
|
||||
expect(fetch.json()).toMatchObject({ success: true, data: { state: 'unsupported', reason: 'adopted' } });
|
||||
const diff = await app.inject({ method: 'GET', url: `/api/sessions/${ctx._sessionId}/git-diff?repo=x&path=y&kind=z` });
|
||||
const diff = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${ctx._sessionId}/git-diff?repo=x&path=y&kind=z`,
|
||||
});
|
||||
expect(diff.statusCode).toBe(400);
|
||||
expect(git).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
|
||||
@@ -94,9 +94,11 @@ describe('Session Options modal structure', () => {
|
||||
expect(modal.match(/<button[^>]*data-tab="git"[^>]*>/)?.[0]).not.toContain('data-claude-only');
|
||||
const open = methodBody('openSessionOptions(sessionId)');
|
||||
expect(open).toContain('.set-rail-item[data-tab="git"]');
|
||||
// Remote and Docker sessions answer 'unsupported' on both git routes and the scheduler never fetches them,
|
||||
// so the entry goes the same way the Claude-only ones do.
|
||||
expect(open).toMatch(/gitTabBtn\.style\.display = session\.remote \|\| session\.docker \? 'none' : ''/);
|
||||
// Remote, Docker and adopted sessions answer 'unsupported' on both git routes and the scheduler never fetches
|
||||
// them, so the entry goes the same way the Claude-only ones do.
|
||||
expect(open).toMatch(
|
||||
/gitTabBtn\.style\.display = session\.remote \|\| session\.docker \|\| session\.adopt \? 'none' : ''/
|
||||
);
|
||||
});
|
||||
|
||||
it('makes the Git rows inert while a session follows the app, the way the keep-warm rows go inert', () => {
|
||||
@@ -150,6 +152,10 @@ describe('Session Options modal structure', () => {
|
||||
const open = methodBody('openSessionOptions(sessionId)');
|
||||
expect(open).toContain("getElementById('modalSessionPath')");
|
||||
expect(open).toContain('session.workingDir');
|
||||
expect(methodBody('async copySessionPath()')).toContain('_copyText(session.workingDir)');
|
||||
// An adopted session's workingDir is the neutral wrapper folder, so the row shows where the pane was.
|
||||
expect(open).toContain('session.adopt?.paneCurrentPath || session.workingDir');
|
||||
const copy = methodBody('async copySessionPath()');
|
||||
expect(copy).toContain('session?.adopt?.paneCurrentPath || session?.workingDir');
|
||||
expect(copy).toContain('_copyText(path)');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user