mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e5f6c8ee1 | ||
|
|
5d2899907e | ||
|
|
8facd5e7e7 | ||
|
|
b54094a4c8 | ||
|
|
2b89f35599 | ||
|
|
ee670c38f6 | ||
|
|
ad57109dcf | ||
|
|
c15b8345b5 | ||
|
|
45ae9f4064 | ||
|
|
816d900857 | ||
|
|
ddc267c6ff | ||
|
|
d66007053b | ||
|
|
f470f3a4e7 | ||
|
|
cb3eecad9b | ||
|
|
db24fc6d7e | ||
|
|
292ba2c775 | ||
|
|
e803186dfe | ||
|
|
474efd9023 | ||
|
|
03bb40c78a | ||
|
|
3ea1ea28f0 | ||
|
|
62008fb408 | ||
|
|
660b320a67 | ||
|
|
529d8fa8ea | ||
|
|
19af37977a | ||
|
|
94e3aae57d | ||
|
|
0a039239e4 |
@@ -1,5 +1,33 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Codeman 1.10.0.
|
||||
|
||||
**Every surface that offers a CLI now checks the CLI is actually there** (#200, #201). The welcome-screen run buttons, the run-mode dropdown and the App Settings "Codex CLI" tab used to be shown unconditionally, so picking one on a box without the binary spawned a session that errored out immediately. All of them now gate on a single server-injected availability object covering Claude, OpenCode, Codex, Gemini, Antigravity and cloudflared, so nothing flickers in after paint and the dropdown costs no round trips to open. Shell is never gated, which is what keeps the menu non-empty on a box with nothing installed, and unknown availability reads as available so a stale page can never leave a working install with nothing to click. Adds `isClaudeAvailable()` and `GET /api/claude/status`, the one CLI that had no availability check despite being the default. The Cloudflare Tunnel welcome button and its scan-to-connect QR are gated on `cloudflared` rather than shown regardless.
|
||||
|
||||
**Shell and remote-SSH sessions now launch a real login shell** (#209, #210). Local shell tabs match what tmux itself does for a pane with no `default-command`, picking up the `/etc/profile` and `/etc/profile.d/*` entries a systemd `--user` service never sourced. On remote SSH, `claude`/`opencode`/`codex`/`gemini`/`agy` are routed through the remote user's interactive login shell, fixing agent CLIs that silently failed with "command not found" because ssh's remote-command execution sees only sshd's minimal default PATH and not the `~/.local/bin` or `~/.opencode/bin` entries where those CLIs actually live. Shell mode uses the remote user's real shell instead of hardcoded bash. The login flags are applied only to shells verified to accept them, so an exotic passwd entry (nushell, elvish, xonsh) cannot produce a dead pane on arrival.
|
||||
|
||||
**A crashed remote pane is kept for diagnosis** (#210), which is how the PATH failure above was found: it previously destroyed the pane, the window and the whole remote session on exit, tearing the local ssh attach down with it and leaving a flap loop with no evidence. Scoped to `remain-on-exit failed`, so a clean `exit` still tears the session down and only a non-zero exit strands anything, and applied last in the tmux command chain so a remote tmux older than 3.2 cannot drop the other session options with it.
|
||||
|
||||
**Resumed sessions under a hidden directory get the right working directory** (#202). Claude Code's project-key encoder maps both `/` and `.` to `-`, and the decoder could not reconstruct a dot-prefixed component, so every session under `~/.codeman` (or any project nested beneath any dotdir) silently resolved to bare `$HOME`. The wrong `workingDir` then propagated into `state.json` and everything trusting it: CLAUDE.md lookup, paste-image directory, subagent and image watchers. A same-named non-dot sibling could also produce a doubled-slash path that failed every later string comparison.
|
||||
|
||||
**Launching a session no longer wipes the terminal you are looking at** (#180). All six run modes route through the shared ownership helpers instead of clearing and writing into whatever session happened to be active, Antigravity included.
|
||||
|
||||
**Codex terminal animations are configurable** (#181), and the App Settings "Codex CLI" tab appears only where the `codex` binary resolves, since both settings on it are handed to `codex` at launch.
|
||||
|
||||
## 1.9.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Two bug fixes.
|
||||
|
||||
**Plain shell sessions could not start when the server process had no `SHELL` (#208).** The tmux pane command for `mode: 'shell'` was the literal string `$SHELL`. That string is embedded in the `bash -c "..."` argument of the `respawn-pane` line, which is run through `/bin/sh -c`, so it was expanded by the _server_ process's shell against the _server_ process's environment rather than inside the pane. Containers and system-level systemd units do not set `SHELL`, so it expanded to nothing and the pane command ended in a dangling `&&`, giving `bash: -c: line 1: syntax error: unexpected end of file` and a pane that died instantly (status 2) while tmux session creation still reported success. The shell is now resolved in Node (`$SHELL`, then the passwd entry, then `/bin/bash`, `/bin/zsh`, `/bin/sh`), requiring an absolute path to an executable and skipping `nologin`-style stubs, then shell-quoted. Only local shell sessions were affected: agent CLI modes emit a real command, and Docker/remote-SSH cases already used a literal `exec bash -l`.
|
||||
|
||||
**A session name typed into the tab options could be silently dropped.** Two independent paths. In the Session Options modal, the Session Name input saves on blur while every autosave handler bails on a null `editingSessionId`, and `closeSessionOptions()` cleared that id before hiding the modal (hiding is what blurs the input), so the save always ran too late; Escape and backdrop-click lost the name with no PUT at all, and only the X button worked because mousedown blurs first. The focused modal field is now blurred before the id is cleared, which also covers the auto-compact prompt. Separately, the right-click inline rename could be destroyed mid-keystroke: the `_inlineRenameActive` guard was missing from `_renderSessionTabsImmediate()`, so a render queued just before the rename opened still rewrote the tab name's innerHTML, committing a truncated name or closing the rename outright. The debounced executor is now guarded too.
|
||||
|
||||
## 1.9.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -74,7 +74,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.9.8 (must match `package.json`)
|
||||
**Version**: 1.10.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.9.8",
|
||||
"version": "1.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.9.8",
|
||||
"version": "1.10.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.9.8",
|
||||
"version": "1.10.0",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+50
-6
@@ -58,17 +58,61 @@ export async function writeRemoteCases(configDir: string, cases: RemoteCase[]):
|
||||
await writeJsonArray(configDir, remoteCasesPath(configDir), cases);
|
||||
}
|
||||
|
||||
/**
|
||||
* The remote user's login shell, defaulted and quoted.
|
||||
*
|
||||
* The default is belt-and-braces, not a live bug: an empty `$SHELL` would expand
|
||||
* to `exec -i -l`, which the shell reads as `exec -i` — "not found", pane dead on
|
||||
* arrival, the #208 failure all over again (verified: `sh -c 'exec $SHELL -i -l'`
|
||||
* with SHELL unset prints `exec: -i: not found`). In practice tmux always exports
|
||||
* SHELL into a pane from its own `default-shell` option, so the command as USED
|
||||
* here is safe either way (also verified). The default matters because these
|
||||
* strings are the seed values a per-host `commands.*` override is edited from, and
|
||||
* nothing constrains where an edited one ends up running. Quoted for a shell path
|
||||
* containing spaces. `/bin/sh` exists on every POSIX host.
|
||||
*/
|
||||
const REMOTE_LOGIN_SHELL = '"${SHELL:-/bin/sh}"';
|
||||
|
||||
/**
|
||||
* Run `command` through the remote user's interactive login shell, so per-user
|
||||
* PATH entries (~/.local/bin, ~/.opencode/bin, …) are resolved before the CLI name
|
||||
* is looked up. ssh's remote-command execution is neither interactive nor login,
|
||||
* so a bare `exec claude` sees only sshd's minimal default PATH and dies with
|
||||
* "command not found" (exit 127).
|
||||
*
|
||||
* Shells that take neither flag (nushell, elvish, …) cannot be detected from here
|
||||
* the way `loginShellArgs()` detects them locally, since the shell is whatever the
|
||||
* REMOTE passwd says. A host like that is what the per-host `commands.*` override
|
||||
* is for.
|
||||
*/
|
||||
export function remoteLoginShellCommand(command: string): string {
|
||||
return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`;
|
||||
}
|
||||
|
||||
export function defaultRemoteCommandForMode(mode: SessionMode): string {
|
||||
// Agent CLIs (claude/opencode/codex/gemini/antigravity) are typically installed
|
||||
// under per-user paths like ~/.local/bin or ~/.opencode/bin, added to PATH only by
|
||||
// the remote user's interactive-login shell startup files (~/.zshrc etc.). ssh's
|
||||
// remote-command execution is neither interactive nor login, so a bare `exec
|
||||
// claude` sees only sshd's minimal default PATH and fails with "command not
|
||||
// found" (exit 127) — confirmed via `tmux capture-pane` on the
|
||||
// remain-on-exit-preserved dead pane. Route through `$SHELL -i -l -c`, the same
|
||||
// fix already used for shell mode below, so PATH is fully resolved before the
|
||||
// CLI name is looked up.
|
||||
const commands: Record<RemoteCommandMode, string> = {
|
||||
shell: 'exec bash -l',
|
||||
// $SHELL, not a hardcoded bash: sshd sets it from the remote user's
|
||||
// /etc/passwd entry, so this launches their actual login shell (zsh,
|
||||
// fish, etc.). -i -l so it sources rc files (~/.zshrc etc.), matching
|
||||
// the local shell-mode launch.
|
||||
shell: `exec ${REMOTE_LOGIN_SHELL} -i -l`,
|
||||
// Mirror the LOCAL claude default so the remote agent runs non-interactively
|
||||
// (no trust-folder/permission prompt that nothing on the remote answers). The
|
||||
// per-host `commands.claude` override stays the escape hatch.
|
||||
claude: 'exec claude --dangerously-skip-permissions',
|
||||
opencode: 'exec opencode',
|
||||
codex: 'exec codex',
|
||||
gemini: 'exec gemini',
|
||||
antigravity: 'exec agy',
|
||||
claude: remoteLoginShellCommand('claude --dangerously-skip-permissions'),
|
||||
opencode: remoteLoginShellCommand('opencode'),
|
||||
codex: remoteLoginShellCommand('codex'),
|
||||
gemini: remoteLoginShellCommand('gemini'),
|
||||
antigravity: remoteLoginShellCommand('agy'),
|
||||
};
|
||||
return commands[mode as RemoteCommandMode] || commands.shell;
|
||||
}
|
||||
|
||||
+4
-2
@@ -68,6 +68,7 @@ import {
|
||||
getClaudeCliVersion,
|
||||
getClaudeBinaryPath,
|
||||
spawnPtyWithHelperRepair,
|
||||
resolveLocalShell,
|
||||
} from './utils/index.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_SIZE,
|
||||
@@ -1898,8 +1899,9 @@ export class Session extends EventEmitter {
|
||||
|
||||
this._resetBuffers();
|
||||
|
||||
// Use user's default shell or bash
|
||||
const shell = process.env.SHELL || '/bin/bash';
|
||||
// Use user's default shell, falling back to a shell that actually exists.
|
||||
// Shared with the tmux pane command so both paths launch the same binary.
|
||||
const shell = resolveLocalShell();
|
||||
console.log(
|
||||
'[Session] Starting shell session with:',
|
||||
shell + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
||||
|
||||
+49
-5
@@ -49,7 +49,12 @@ import {
|
||||
type DockerCommandMode,
|
||||
} from './types.js';
|
||||
import { buildEffortCliArgs } from './session-cli-builder.js';
|
||||
import { buildSshConnectionArgs, defaultRemoteCommandForMode, remoteSshTarget } from './remote-hosts.js';
|
||||
import {
|
||||
buildSshConnectionArgs,
|
||||
defaultRemoteCommandForMode,
|
||||
remoteLoginShellCommand,
|
||||
remoteSshTarget,
|
||||
} from './remote-hosts.js';
|
||||
import {
|
||||
buildDockerBaseArgs,
|
||||
buildDockerCreateArgs,
|
||||
@@ -71,6 +76,8 @@ import {
|
||||
resolveCodexDir,
|
||||
resolveGeminiDir,
|
||||
resolveAntigravityDir,
|
||||
resolveLocalShell,
|
||||
loginShellArgs,
|
||||
} from './utils/index.js';
|
||||
import type {
|
||||
TerminalMultiplexer,
|
||||
@@ -642,6 +649,10 @@ export function buildCodexCommand(config?: CodexConfig): string {
|
||||
parts.push('--dangerously-bypass-approvals-and-sandbox');
|
||||
}
|
||||
|
||||
if (config?.animations !== undefined) {
|
||||
parts.push('--config', `tui.animations=${config.animations ? 'true' : 'false'}`);
|
||||
}
|
||||
|
||||
if (config?.model) {
|
||||
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
|
||||
if (safeModel) parts.push('--model', safeModel);
|
||||
@@ -773,7 +784,21 @@ export function buildSpawnCommand(options: {
|
||||
if (options.mode === 'antigravity') {
|
||||
return buildAntigravityCommand(options.antigravityConfig);
|
||||
}
|
||||
return '$SHELL';
|
||||
// #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"`
|
||||
// argument of the respawn-pane line, which execSync runs through `/bin/sh -c`,
|
||||
// so a `$SHELL` here is expanded by the SERVER process's shell against the
|
||||
// SERVER process's env — empty in containers and system systemd units, leaving
|
||||
// the pane command ending in a dangling `&&` ("syntax error: unexpected end of
|
||||
// file", pane dead on arrival). Resolve it in Node and quote the result.
|
||||
// #209: launch it as a LOGIN shell, which is what tmux itself does for a pane
|
||||
// with no `default-command`, so a Codeman shell tab matches a hand-started tmux
|
||||
// one. That is what picks up /etc/profile and /etc/profile.d/* — a systemd
|
||||
// --user service never sourced them, so its PATH is what every pane inherited.
|
||||
// The flags come from loginShellArgs() rather than being hardcoded: they are
|
||||
// appended to a path that ultimately comes from the passwd entry, and a shell
|
||||
// that rejects an unknown flag exits on the spot, which is #208 all over again.
|
||||
const shell = resolveLocalShell();
|
||||
return `${shellescape(shell)}${loginShellArgs(shell)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -845,13 +870,14 @@ export function buildRemoteLaunchCommand(options: {
|
||||
// hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's
|
||||
// downgraded 'auto' actually reaches the remote agent (the default command otherwise
|
||||
// ignored claudeMode). A per-host `commands.claude` override stays authoritative
|
||||
// (admin's explicit choice). For the DEFAULT single-user config (skip), the emitted
|
||||
// command is byte-identical to before. Non-claude modes are unchanged.
|
||||
// (admin's explicit choice). Wrapped in `$SHELL -i -l -c` for the same reason as
|
||||
// `defaultRemoteCommandForMode`: `claude` lives under a per-user PATH entry that
|
||||
// only an interactive login shell resolves (see that function's comment).
|
||||
const override = remote.commands?.[mode];
|
||||
const modeCommand = override
|
||||
? override
|
||||
: mode === 'claude'
|
||||
? `exec claude${buildClaudePermissionFlags(claudeMode, allowedTools)}`
|
||||
? remoteLoginShellCommand(`claude${buildClaudePermissionFlags(claudeMode, allowedTools)}`)
|
||||
: defaultRemoteCommandForMode(mode);
|
||||
const remoteName = remoteTmuxSessionName(sessionId);
|
||||
|
||||
@@ -877,6 +903,24 @@ export function buildRemoteLaunchCommand(options: {
|
||||
// Per-session scoped (`set -t <name>`, matching #145's hardening) so a shared
|
||||
// remote tmux server's other sessions keep their own sizing behavior.
|
||||
`set -t ${remoteName} window-size latest`,
|
||||
// #210: keep a CRASHED pane so the failure is still on screen. Without this,
|
||||
// tmux destroys the pane -> window -> session (and, being the only session,
|
||||
// the whole remote server) the instant the pane command exits, which tears the
|
||||
// local `ssh -t` attach down with it; reconnect's `-A` then builds a fresh
|
||||
// session and the cycle can repeat as a flap loop with no evidence surviving.
|
||||
// That is how the exit-127 PATH bug fixed above stayed invisible.
|
||||
//
|
||||
// `failed`, NOT `on`: `on` keeps the pane on a CLEAN exit too, so typing
|
||||
// `exit` in a remote shell leaves a dead pane behind, the session outlives it,
|
||||
// and the next launch's `-A` reattaches to that corpse ("Pane is dead (status
|
||||
// 0)") instead of starting a shell — verified against a real tmux. `failed`
|
||||
// keeps the pane only on a non-zero exit, which is exactly the diagnostic case.
|
||||
//
|
||||
// LAST in the chain on purpose: tmux aborts the remaining commands of a `\;`
|
||||
// sequence once one errors (also verified), and `failed` needs tmux >= 3.2 on
|
||||
// the REMOTE host. Trailing, a rejection costs only this option; leading, it
|
||||
// would silently drop status/mouse/prefix/escape-time/window-size with it.
|
||||
`set -t ${remoteName} remain-on-exit failed`,
|
||||
].join(' \\; ');
|
||||
|
||||
// ssh runs its trailing args through the remote login shell, so the entire
|
||||
|
||||
+5
-20
@@ -15,10 +15,8 @@
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { resolveCloudflaredPath } from './utils/cloudflared-resolver.js';
|
||||
import {
|
||||
QR_TOKEN_TTL_MS,
|
||||
QR_TOKEN_GRACE_MS,
|
||||
@@ -95,23 +93,10 @@ export class TunnelManager extends EventEmitter {
|
||||
private resolveCloudflared(): string | null {
|
||||
if (this.cloudflaredPath) return this.cloudflaredPath;
|
||||
|
||||
// Check ~/.local/bin first (common user install location)
|
||||
const localBin = join(homedir(), '.local', 'bin', 'cloudflared');
|
||||
if (existsSync(localBin)) {
|
||||
this.cloudflaredPath = localBin;
|
||||
return localBin;
|
||||
}
|
||||
|
||||
// Check /usr/local/bin
|
||||
const usrLocalBin = '/usr/local/bin/cloudflared';
|
||||
if (existsSync(usrLocalBin)) {
|
||||
this.cloudflaredPath = usrLocalBin;
|
||||
return usrLocalBin;
|
||||
}
|
||||
|
||||
// Fall back to PATH
|
||||
this.cloudflaredPath = 'cloudflared';
|
||||
return 'cloudflared';
|
||||
// Shared with the welcome-screen availability check, so the button and the
|
||||
// spawn can never disagree about where cloudflared lives.
|
||||
this.cloudflaredPath = resolveCloudflaredPath() ?? 'cloudflared';
|
||||
return this.cloudflaredPath;
|
||||
}
|
||||
|
||||
/** Clear all pending timers */
|
||||
|
||||
@@ -305,6 +305,8 @@ export interface CodexConfig {
|
||||
resumeSessionId?: string;
|
||||
/** Bypass approval prompts (passes --dangerously-bypass-approvals-and-sandbox) */
|
||||
dangerouslyBypassApprovals?: boolean;
|
||||
/** Enable Codex's decorative TUI animations. Disable to reduce remote terminal redraws. */
|
||||
animations?: boolean;
|
||||
/** Browser rendering strategy for Codex sessions. Hybrid TUI is the only supported mode. */
|
||||
renderMode?: CodexRenderMode;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,15 @@ const CLAUDE_SEARCH_DIRS = [
|
||||
/** Cached directory containing the claude binary (empty string = searched but not found) */
|
||||
let _claudeDir: string | null = null;
|
||||
|
||||
/**
|
||||
* Returns true if the Claude CLI binary can be located (via `which` or one of
|
||||
* the common install directories). Mirrors `isGeminiAvailable`/`isOpenCodeAvailable`/
|
||||
* `isCodexAvailable` in the sibling resolvers.
|
||||
*/
|
||||
export function isClaudeAvailable(): boolean {
|
||||
return findClaudeDir() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the directory containing the `claude` binary.
|
||||
* Checks `which claude` first, then falls back to common install locations.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @fileoverview Resolve the `cloudflared` binary across common install paths.
|
||||
*
|
||||
* Mirrors the CLI resolvers (gemini-cli-resolver.ts et al), for the same reason
|
||||
* they exist: the welcome screen should not offer a button whose only possible
|
||||
* outcome is an error toast.
|
||||
*
|
||||
* The search list is deliberately the SAME one `TunnelManager.resolveCloudflared()`
|
||||
* has always used, and that method now delegates here so the two can never drift.
|
||||
* The difference is the fallback: this module answers "is it installed?" honestly
|
||||
* with null, while the tunnel manager keeps falling back to the bare name so a
|
||||
* cloudflared that only exists somewhere on the tunnel process's PATH still
|
||||
* starts. A stricter answer there would turn a working tunnel into a refusal.
|
||||
*
|
||||
* @module utils/cloudflared-resolver
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
|
||||
/** Common directories where the cloudflared binary may be installed */
|
||||
const CLOUDFLARED_SEARCH_DIRS = [join(homedir(), '.local', 'bin'), '/usr/local/bin'];
|
||||
|
||||
/** Cached path to the cloudflared binary (empty string = searched but not found) */
|
||||
let _cloudflaredPath: string | null = null;
|
||||
|
||||
/**
|
||||
* Finds the `cloudflared` binary.
|
||||
*
|
||||
* @returns Absolute path, or null if not found
|
||||
*/
|
||||
export function resolveCloudflaredPath(): string | null {
|
||||
if (_cloudflaredPath !== null) return _cloudflaredPath || null;
|
||||
|
||||
for (const dir of CLOUDFLARED_SEARCH_DIRS) {
|
||||
const candidate = join(dir, 'cloudflared');
|
||||
if (existsSync(candidate)) {
|
||||
_cloudflaredPath = candidate;
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = execSync('which cloudflared', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim();
|
||||
if (result && existsSync(result)) {
|
||||
_cloudflaredPath = result;
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// Not on PATH either.
|
||||
}
|
||||
|
||||
_cloudflaredPath = ''; // mark as searched, not found
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cloudflared is available on the system.
|
||||
*/
|
||||
export function isCloudflaredAvailable(): boolean {
|
||||
return resolveCloudflaredPath() !== null;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export { isSafePushEndpoint } from './push-endpoint-validation.js';
|
||||
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
|
||||
export { assertNever } from './type-safety.js';
|
||||
export { wrapWithNice } from './nice-wrapper.js';
|
||||
export { resolveLocalShell, loginShellArgs } from './shell-resolver.js';
|
||||
export { findClaudeDir, getAugmentedPath, getClaudeCliVersion, getClaudeBinaryPath } from './claude-cli-resolver.js';
|
||||
export { spawnPtyWithHelperRepair } from './node-pty-repair.js';
|
||||
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @fileoverview Resolve a real, launchable login shell for `mode: 'shell'` sessions.
|
||||
*
|
||||
* The tmux pane command for a local shell session used to be the literal string
|
||||
* `$SHELL`. That string is embedded in the `bash -c "…"` argument of the
|
||||
* `respawn-pane` line, which `execSync` hands to `/bin/sh -c` — so `$SHELL` was
|
||||
* expanded by the SERVER process's shell (not the pane's), against the SERVER
|
||||
* process's env. Containers and system-level systemd units do not set `SHELL`,
|
||||
* so the expansion produced an empty string and the pane command ended in a
|
||||
* dangling `&&`:
|
||||
*
|
||||
* bash -c "cd \"/case\" && ulimit … && export … && "
|
||||
* -> bash: -c: line 1: syntax error: unexpected end of file
|
||||
*
|
||||
* The pane then died instantly (status 2) while tmux creation itself reported
|
||||
* success, which is exactly what issue #208 saw. Resolving the shell HERE, in
|
||||
* Node, removes the shell-expansion layer entirely and guarantees a non-empty
|
||||
* absolute path.
|
||||
*
|
||||
* @module utils/shell-resolver
|
||||
*/
|
||||
|
||||
import { accessSync, constants } from 'node:fs';
|
||||
import { userInfo } from 'node:os';
|
||||
|
||||
/** Last-resort shells, in preference order. `/bin/sh` exists on every POSIX host. */
|
||||
const FALLBACK_SHELLS = ['/bin/bash', '/bin/zsh', '/bin/sh'];
|
||||
|
||||
/**
|
||||
* Shells that exist and are executable but immediately exit — a service account's
|
||||
* passwd entry commonly points at one, which would look identical to the crash
|
||||
* this module exists to prevent.
|
||||
*/
|
||||
const NON_INTERACTIVE_SHELLS = new Set(['nologin', 'false', 'true', 'sync']);
|
||||
|
||||
/**
|
||||
* Shells verified to accept BOTH `-i` and `-l`. Deliberately an allowlist, not a
|
||||
* blocklist: a shell that rejects an unknown flag exits immediately, which is the
|
||||
* dead-pane-on-arrival failure this module exists to prevent (#208). The passwd
|
||||
* entry is user data and can name anything — nushell, elvish, and xonsh all take
|
||||
* neither flag in this form, so they get a bare launch instead of a dead tab.
|
||||
*
|
||||
* csh/tcsh are excluded on purpose: tcsh honors `-l` only when it is the ONLY
|
||||
* flag, so `-i -l` would silently not be a login shell there anyway.
|
||||
*/
|
||||
const LOGIN_FLAG_SHELLS = new Set(['sh', 'bash', 'dash', 'ash', 'zsh', 'ksh', 'ksh93', 'mksh', 'pdksh', 'fish']);
|
||||
|
||||
function isUsableShell(candidate: string): boolean {
|
||||
if (!candidate.startsWith('/')) return false;
|
||||
const base = candidate.slice(candidate.lastIndexOf('/') + 1);
|
||||
if (NON_INTERACTIVE_SHELLS.has(base)) return false;
|
||||
try {
|
||||
accessSync(candidate, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an absolute path to an interactive shell, preferring the user's own.
|
||||
*
|
||||
* Order: `$SHELL` -> the passwd entry -> `/bin/bash` -> `/bin/zsh` -> `/bin/sh`.
|
||||
* Every candidate must be an absolute path to an executable that is not a
|
||||
* nologin-style stub. Always returns a non-empty string.
|
||||
*/
|
||||
export function resolveLocalShell(): string {
|
||||
const candidates: string[] = [];
|
||||
|
||||
const envShell = process.env.SHELL?.trim();
|
||||
if (envShell) candidates.push(envShell);
|
||||
|
||||
try {
|
||||
// Throws when the uid has no /etc/passwd entry (common for `--user` containers).
|
||||
const passwdShell = userInfo().shell?.trim();
|
||||
if (passwdShell) candidates.push(passwdShell);
|
||||
} catch {
|
||||
/* no passwd entry — fall through to the static fallbacks */
|
||||
}
|
||||
|
||||
candidates.push(...FALLBACK_SHELLS);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (isUsableShell(candidate)) return candidate;
|
||||
}
|
||||
|
||||
// Nothing was verifiable (exotic/read-restricted image). /bin/sh is still the
|
||||
// best guess and is far better than emitting an empty command.
|
||||
return '/bin/sh';
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags that make `shellPath` a login shell, or `''` when it takes none we trust.
|
||||
*
|
||||
* A tmux pane already hands the shell a tty, so it is interactive with or without
|
||||
* `-i` (verified: `$-` contains `i` for a bare `/bin/bash` in a pane, which is why
|
||||
* `~/.bashrc` has always been sourced). The flag that actually changes anything is
|
||||
* `-l`: it makes the pane a LOGIN shell, matching what tmux itself does when it
|
||||
* spawns a pane with no `default-command`, and picking up the `/etc/profile` and
|
||||
* `/etc/profile.d/*` PATH entries that a systemd-spawned server never sourced.
|
||||
*
|
||||
* `-i` is kept alongside it because for bash the two select different files —
|
||||
* login reads `~/.bash_profile`, interactive-non-login reads `~/.bashrc` — and
|
||||
* asking for both is the closest thing to "the shell the user actually gets".
|
||||
*
|
||||
* Returns a string ready to append to an already-escaped shell path.
|
||||
*/
|
||||
export function loginShellArgs(shellPath: string): string {
|
||||
const base = shellPath.slice(shellPath.lastIndexOf('/') + 1);
|
||||
return LOGIN_FLAG_SHELLS.has(base) ? ' -i -l' : '';
|
||||
}
|
||||
@@ -3290,6 +3290,13 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
_renderSessionTabsImmediate() {
|
||||
// Same guard as renderSessionTabs()/_fullRenderSessionTabs(): the incremental
|
||||
// branch below rewrites .tab-name's innerHTML, which destroys the inline rename
|
||||
// <input> mid-keystroke. Guarding only the scheduler is not enough: a render
|
||||
// debounced just BEFORE the rename opened still fires ~100ms later and lands
|
||||
// here directly. finishRename() re-renders on both commit and cancel, so a
|
||||
// render dropped here is picked back up when the rename settles.
|
||||
if (this._inlineRenameActive) return;
|
||||
const container = this.$('sessionTabs');
|
||||
const existingTabs = container.querySelectorAll('.session-tab[data-id]');
|
||||
const existingIds = new Set([...existingTabs].map(t => t.dataset.id));
|
||||
|
||||
@@ -311,19 +311,19 @@
|
||||
<h1 class="welcome-title">Codeman</h1>
|
||||
<p class="welcome-desc">Manage AI Coding tools in persistent tmux sessions.</p>
|
||||
<div class="welcome-actions">
|
||||
<button class="welcome-btn welcome-btn-claude" onclick="app.setRunMode('claude'); app.runClaude()">
|
||||
<button class="welcome-btn welcome-btn-claude" id="welcomeClaudeBtn" style="display: none;" onclick="app.setRunMode('claude'); app.runClaude()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Claude Code
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-tunnel" id="welcomeTunnelBtn" onclick="app.toggleTunnelFromWelcome()">
|
||||
<button class="welcome-btn welcome-btn-tunnel" id="welcomeTunnelBtn" style="display: none;" onclick="app.toggleTunnelFromWelcome()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
Cloudflare Tunnel
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-opencode" onclick="app.setRunMode('opencode'); app.runOpenCode()">
|
||||
<button class="welcome-btn welcome-btn-opencode" id="welcomeOpencodeBtn" style="display: none;" onclick="app.setRunMode('opencode'); app.runOpenCode()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run OpenCode
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-gemini" onclick="app.setRunMode('gemini'); app.runGemini()">
|
||||
<button class="welcome-btn welcome-btn-gemini" id="welcomeGeminiBtn" style="display: none;" onclick="app.setRunMode('gemini'); app.runGemini()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Gemini
|
||||
</button>
|
||||
@@ -1688,6 +1688,14 @@
|
||||
</label>
|
||||
<span class="form-hint">Start new Codex sessions with --dangerously-bypass-approvals-and-sandbox</span>
|
||||
</div>
|
||||
<div class="form-row form-row-switch">
|
||||
<label>Animated Status Effects</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="appSettingsCodexAnimations">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="form-hint">Decorative Codex TUI motion for new local sessions. Leave off to reduce remote and mobile redraws.</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Models Tab -->
|
||||
<div class="modal-tab-content hidden" id="settings-models">
|
||||
|
||||
@@ -848,6 +848,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
closeSessionOptions() {
|
||||
// Commit the field the user was still editing BEFORE editingSessionId is
|
||||
// cleared. The Session Name input saves on blur (and the auto-compact prompt
|
||||
// on change), and every autosave handler bails out on `!this.editingSessionId`.
|
||||
// Hiding the modal blurs the focused input on its own, but that happens after
|
||||
// the id is gone, so Escape / backdrop-click silently dropped what was typed.
|
||||
// (Clicking the X worked only because mousedown blurs the input first.)
|
||||
const modal = document.getElementById('sessionOptionsModal');
|
||||
const focused = document.activeElement;
|
||||
if (focused && modal && modal.contains(focused) && typeof focused.blur === 'function') {
|
||||
focused.blur();
|
||||
}
|
||||
|
||||
this.editingSessionId = null;
|
||||
// Stop run summary auto-refresh if it was running
|
||||
this.stopRunSummaryAutoRefresh();
|
||||
|
||||
@@ -441,6 +441,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Load history sessions when menu opens
|
||||
if (menu.classList.contains('active')) {
|
||||
this._loadRunModeHistory();
|
||||
this._refreshRunModeAvailability(menu);
|
||||
const close = (ev) => {
|
||||
if (!menu.contains(ev.target)) {
|
||||
menu.classList.remove('active');
|
||||
@@ -451,6 +452,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* #201: hides run-mode dropdown entries for CLIs that aren't installed, so
|
||||
* picking one doesn't spawn a session that immediately errors out.
|
||||
*
|
||||
* Shell has no external CLI dependency and is never gated, which is also what
|
||||
* guarantees the menu is never empty. Scoped to `menu` rather than the document:
|
||||
* `.run-mode-option` is also the class the saved-dashboard rows and the history
|
||||
* rows use, and a bare querySelector would find whichever came first in the DOM.
|
||||
*
|
||||
* Antigravity is in this list even though #201 predates it — it is a run mode
|
||||
* like the rest, and `agy` is the LEAST likely of the five to be installed.
|
||||
*/
|
||||
_refreshRunModeAvailability(menu) {
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity']) {
|
||||
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
|
||||
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
|
||||
}
|
||||
},
|
||||
|
||||
async _loadRunModeHistory() {
|
||||
const container = document.getElementById('runModeHistory');
|
||||
if (!container) return;
|
||||
@@ -582,13 +602,43 @@ Object.assign(CodemanApp.prototype, {
|
||||
return startNumber;
|
||||
},
|
||||
|
||||
/**
|
||||
* Launch progress may use the terminal only on the session-less home screen.
|
||||
* When another session is active, mutating the shared xterm would serialize
|
||||
* launch chrome into that session's snapshot during the subsequent switch.
|
||||
*/
|
||||
_beginSessionLaunchStatus(message, ansiColor = '1;32') {
|
||||
const ownsTerminal = !this.activeSessionId;
|
||||
if (ownsTerminal) {
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[${ansiColor}m ${message}\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
} else {
|
||||
this.showToast?.(message, 'info');
|
||||
}
|
||||
return ownsTerminal;
|
||||
},
|
||||
|
||||
_appendSessionLaunchStatus(ownsTerminal, message, ansiColor = '90') {
|
||||
if (!ownsTerminal || this.activeSessionId) return;
|
||||
this.terminal.writeln(`\x1b[${ansiColor}m ${message}\x1b[0m`);
|
||||
},
|
||||
|
||||
_reportSessionLaunchError(ownsTerminal, message) {
|
||||
if (ownsTerminal && !this.activeSessionId) {
|
||||
this.terminal.writeln(`\x1b[1;31m Error: ${message}\x1b[0m`);
|
||||
} else {
|
||||
this.showToast?.(message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async runClaude() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1));
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting ${tabCount} Claude session(s) in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Claude session(s) in ${caseName}...`
|
||||
);
|
||||
// Focus terminal NOW, in the synchronous user-gesture context (button click).
|
||||
// iOS Safari ignores programmatic focus() after any await, so this must happen
|
||||
// before the first async call. The keyboard opens here and stays open through
|
||||
@@ -671,7 +721,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
remoteIds.push(data.data.sessionId);
|
||||
}
|
||||
this.terminal.writeln(`\x1b[90m All ${tabCount} remote session(s) ready\x1b[0m`);
|
||||
this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`);
|
||||
if (remoteIds[0]) {
|
||||
await this.selectSession(remoteIds[0]);
|
||||
this.loadQuickStartCases();
|
||||
@@ -706,7 +756,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const modelOverride = globalSettings.claudeModel || (useOpus1m ? 'opus[1m]' : '');
|
||||
|
||||
// Step 1: Create all sessions in parallel
|
||||
this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`);
|
||||
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Creating ${tabCount} session(s)...`);
|
||||
const createPromises = sessionNames.map(name =>
|
||||
fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
@@ -747,12 +797,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
));
|
||||
|
||||
// Step 3: Start all sessions in parallel (biggest speedup)
|
||||
this.terminal.writeln(`\x1b[90m Starting ${tabCount} session(s) in parallel...\x1b[0m`);
|
||||
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Starting ${tabCount} session(s) in parallel...`);
|
||||
await Promise.all(sessionIds.map(id =>
|
||||
fetch(`/api/sessions/${id}/interactive`, { method: 'POST' })
|
||||
));
|
||||
|
||||
this.terminal.writeln(`\x1b[90m All ${tabCount} sessions ready\x1b[0m`);
|
||||
this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} sessions ready`);
|
||||
|
||||
// Auto-switch to the new session using selectSession (does proper refresh)
|
||||
if (firstSessionId) {
|
||||
@@ -762,7 +812,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -805,9 +855,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const shellCount = Math.min(20, Math.max(1, parseInt(document.getElementById('shellCount').value) || 1));
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;33m Starting ${shellCount} Shell session(s) in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${shellCount} Shell session(s) in ${caseName}...`,
|
||||
'1;33'
|
||||
);
|
||||
|
||||
try {
|
||||
// Get the case path
|
||||
@@ -913,7 +964,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -924,9 +975,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OpenCode session in ${caseName}...`);
|
||||
// Focus in sync gesture context (see runClaude comment)
|
||||
this.terminal.focus();
|
||||
|
||||
@@ -936,8 +985,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const statusRes = await fetch('/api/opencode/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m');
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -970,7 +1021,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -981,9 +1032,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting Codex session in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Codex session in ${caseName}...`);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
@@ -991,8 +1040,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const statusRes = await fetch('/api/codex/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m Codex CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: npm install -g @openai/codex\x1b[0m');
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1009,6 +1060,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
...(isRemote ? {} : {
|
||||
codexConfig: {
|
||||
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
||||
animations: globalSettings.codexAnimationsEnabled ?? false,
|
||||
renderMode: 'hybrid',
|
||||
},
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
@@ -1027,7 +1079,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1038,9 +1090,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting Gemini session in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Gemini session in ${caseName}...`);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
@@ -1048,8 +1098,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const statusRes = await fetch('/api/gemini/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m Gemini CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: npm install -g @google/gemini-cli\x1b[0m');
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1078,7 +1130,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1089,9 +1141,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting Antigravity session in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Antigravity session in ${caseName}...`);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
@@ -1099,8 +1149,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const statusRes = await fetch('/api/antigravity/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m Antigravity CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash\x1b[0m');
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1129,7 +1181,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -373,9 +373,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
claudeModeSelect.onchange = () => {
|
||||
allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none';
|
||||
};
|
||||
// Codex CLI settings
|
||||
// Codex CLI settings. The inputs are always populated (and always read back
|
||||
// by saveAppSettings), even when the tab is hidden below, so a user without
|
||||
// codex installed can never silently wipe the codex prefs of an instance
|
||||
// that does have it.
|
||||
document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked =
|
||||
settings.codexDangerouslyBypassApprovals ?? false;
|
||||
document.getElementById('appSettingsCodexAnimations').checked =
|
||||
settings.codexAnimationsEnabled ?? false;
|
||||
this._applyCodexSettingsVisibility();
|
||||
// Claude Permissions settings
|
||||
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
||||
document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? '';
|
||||
@@ -485,6 +491,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.activeFocusTrap.activate();
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the App Settings "Codex CLI" tab only on instances where the codex
|
||||
* binary actually resolves. Both settings on it (approval bypass, animated
|
||||
* status effects) are passed to `codex` at launch, so on a box without codex
|
||||
* the tab is a promise nothing can keep.
|
||||
*
|
||||
* Availability comes from the injected `window.__codemanCliAvailable`, shared
|
||||
* with the welcome buttons and the run-mode dropdown, so the tab never flickers
|
||||
* in and back out. Only the tab BUTTON is toggled: the panel already carries
|
||||
* `.modal-tab-content.hidden` unless it is the selected tab, and
|
||||
* openAppSettings() always reopens on Display, so an unreachable button is
|
||||
* enough to keep the panel unreachable.
|
||||
*
|
||||
* Note the inverted default versus the run buttons: an UNKNOWN flag hides this
|
||||
* tab. Hiding a settings tab costs a user nothing (the values stay in the DOM
|
||||
* and are still saved), whereas hiding a run button would leave a working
|
||||
* install with nothing to click.
|
||||
*/
|
||||
_applyCodexSettingsVisibility() {
|
||||
const btn = document.querySelector('#appSettingsModal .modal-tab-btn[data-tab="settings-codex"]');
|
||||
if (btn) btn.style.display = window.__codemanCliAvailable?.codex === true ? '' : 'none';
|
||||
},
|
||||
|
||||
switchSettingsTab(tabName) {
|
||||
const modal = document.getElementById('appSettingsModal');
|
||||
// Toggle active class on tab buttons
|
||||
@@ -694,6 +723,42 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._updatePollTimer = setInterval(poll, 1500);
|
||||
},
|
||||
|
||||
/**
|
||||
* Is `tool` installed on the server? Reads `window.__codemanCliAvailable`,
|
||||
* injected by renderIndexHtml (see the comment there for why this is injected
|
||||
* rather than fetched per surface).
|
||||
*
|
||||
* Unknown reads as AVAILABLE. A missing flag means the page was rendered by a
|
||||
* build that predates the injection, or by a solo popup: hiding every run
|
||||
* button on a doubt would leave nothing to click, and the pre-existing failure
|
||||
* mode for a genuinely missing CLI is just an error toast.
|
||||
*/
|
||||
isCliAvailable(tool) {
|
||||
const flags = window.__codemanCliAvailable;
|
||||
if (!flags || typeof flags !== 'object') return true;
|
||||
return flags[tool] !== false;
|
||||
},
|
||||
|
||||
/**
|
||||
* #200: show a welcome-screen button only where the thing it launches exists.
|
||||
* The markup ships them hidden, so an old cached page can never flash a button
|
||||
* for a tool this server does not have.
|
||||
*/
|
||||
applyWelcomeCliVisibility() {
|
||||
const buttons = [
|
||||
['welcomeClaudeBtn', 'claude'],
|
||||
['welcomeOpencodeBtn', 'opencode'],
|
||||
['welcomeGeminiBtn', 'gemini'],
|
||||
// Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
|
||||
// without cloudflared can only ever produce "cloudflared not found".
|
||||
['welcomeTunnelBtn', 'cloudflared'],
|
||||
];
|
||||
for (const [id, tool] of buttons) {
|
||||
const btn = document.getElementById(id);
|
||||
if (btn) btn.style.display = this.isCliAvailable(tool) ? 'flex' : 'none';
|
||||
}
|
||||
},
|
||||
|
||||
async loadTunnelStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/tunnel/status');
|
||||
@@ -1482,6 +1547,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
||||
// Codex CLI settings
|
||||
codexDangerouslyBypassApprovals: document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked,
|
||||
codexAnimationsEnabled: document.getElementById('appSettingsCodexAnimations').checked,
|
||||
// Claude Permissions settings
|
||||
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
||||
claudeModel: document.getElementById('appSettingsClaudeModel').value,
|
||||
|
||||
@@ -1195,6 +1195,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (overlay) {
|
||||
overlay.classList.add('visible');
|
||||
this.loadTunnelStatus();
|
||||
this.applyWelcomeCliVisibility();
|
||||
this.loadHistorySessions();
|
||||
this.initSearchPanel();
|
||||
}
|
||||
|
||||
@@ -2551,7 +2551,12 @@ export function registerSessionRoutes(
|
||||
for (let end = maxLook - 1; end >= idx; end--) {
|
||||
const candidates: string[] = [];
|
||||
if (end === idx) {
|
||||
candidates.push(segments[idx]);
|
||||
// Skip an EMPTY segment: `isDir(current + '/' + '')` stats `current + '/'`,
|
||||
// which always succeeds, so the empty candidate would match unconditionally
|
||||
// and swallow the doubled dash that is the whole signature of a dotdir. It
|
||||
// then resolves "/home/x/.sib" to "/home/x//sib" whenever a non-dot sibling
|
||||
// exists, and shadows the dotdir branch below in every other case.
|
||||
if (segments[idx] !== '') candidates.push(segments[idx]);
|
||||
} else {
|
||||
candidates.push(segments.slice(idx, end + 1).join('-'));
|
||||
candidates.push(segments.slice(idx, end + 1).join('_'));
|
||||
@@ -2564,6 +2569,25 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The encoder maps both '/' and '.' to '-', so a literal '.' in the
|
||||
// original path (e.g. "/home/timkjr/.codeman") collapses into an empty
|
||||
// split segment here. Retry this window as a dotdir/dotfile: ".<join>".
|
||||
if (segments[idx] === '' && idx + 1 < segments.length) {
|
||||
const dotMaxLook = Math.min(idx + 1 + 4, segments.length);
|
||||
for (let end = dotMaxLook - 1; end >= idx + 1; end--) {
|
||||
const dotCandidates =
|
||||
end === idx + 1
|
||||
? [segments[idx + 1]]
|
||||
: [segments.slice(idx + 1, end + 1).join('-'), segments.slice(idx + 1, end + 1).join('_')];
|
||||
for (const child of dotCandidates) {
|
||||
const candidate = current + '/.' + child;
|
||||
if (await isDir(candidate)) {
|
||||
const result = await tryDecode(end + 1, candidate);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2581,7 +2605,10 @@ export function registerSessionRoutes(
|
||||
for (let end = i; end < maxLook; end++) {
|
||||
const candidates: string[] = [];
|
||||
if (end === i) {
|
||||
candidates.push(segments[i]);
|
||||
// Same empty-segment skip as tryDecode above. This loop is shortest-match
|
||||
// first, so without it the empty candidate matches on the very first try
|
||||
// and sets `matched`, leaving the dotdir branch below permanently dead.
|
||||
if (segments[i] !== '') candidates.push(segments[i]);
|
||||
} else {
|
||||
candidates.push(segments.slice(i, end + 1).join('_'));
|
||||
candidates.push(segments.slice(i, end + 1).join('-'));
|
||||
@@ -2597,9 +2624,41 @@ export function registerSessionRoutes(
|
||||
}
|
||||
if (matched) break;
|
||||
}
|
||||
if (!matched && segments[i] === '' && i + 1 < segments.length) {
|
||||
const dotMaxLook = Math.min(i + 1 + 4, segments.length);
|
||||
for (let end = i + 1; end < dotMaxLook; end++) {
|
||||
const dotCandidates =
|
||||
end === i + 1
|
||||
? [segments[i + 1]]
|
||||
: [segments.slice(i + 1, end + 1).join('_'), segments.slice(i + 1, end + 1).join('-')];
|
||||
for (const child of dotCandidates) {
|
||||
const candidate = current + '/.' + child;
|
||||
if (await isDir(candidate)) {
|
||||
current = candidate;
|
||||
i = end + 1;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
current = current + '/' + segments[i];
|
||||
i++;
|
||||
if (segments[i] === '') {
|
||||
// Nothing on disk matched (the usual reason this fallback runs at all is
|
||||
// that the directory was deleted). An empty segment still means the
|
||||
// encoder ate a literal '.', so guess the dotdir form rather than
|
||||
// appending a bare '/' and emitting a "//" path.
|
||||
if (i + 1 < segments.length) {
|
||||
current = current + '/.' + segments[i + 1];
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
current = current + '/' + segments[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
const finalExists = await fs
|
||||
|
||||
@@ -374,9 +374,19 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CLI Integrations (OpenCode, Codex, Gemini, Antigravity)
|
||||
// CLI Integrations (Claude, OpenCode, Codex, Gemini, Antigravity)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Claude ==========
|
||||
|
||||
app.get('/api/claude/status', async () => {
|
||||
const { isClaudeAvailable, findClaudeDir } = await import('../../utils/claude-cli-resolver.js');
|
||||
return {
|
||||
available: isClaudeAvailable(),
|
||||
path: findClaudeDir(),
|
||||
};
|
||||
});
|
||||
|
||||
// ========== OpenCode ==========
|
||||
|
||||
app.get('/api/opencode/status', async () => {
|
||||
|
||||
@@ -182,6 +182,7 @@ const CodexConfigSchema = z
|
||||
.regex(/^[a-zA-Z0-9_-]+$/)
|
||||
.optional(),
|
||||
dangerouslyBypassApprovals: z.boolean().optional(),
|
||||
animations: z.boolean().optional(),
|
||||
renderMode: z
|
||||
.enum(['scrollback', 'hybrid'])
|
||||
.optional()
|
||||
@@ -776,6 +777,7 @@ export const SettingsUpdateSchema = z
|
||||
allowedTools: z.string().max(2000).optional(),
|
||||
// Codex CLI settings
|
||||
codexDangerouslyBypassApprovals: z.boolean().optional(),
|
||||
codexAnimationsEnabled: z.boolean().optional(),
|
||||
// Terminal history and retention
|
||||
terminalScrollbackLines: z
|
||||
.number()
|
||||
|
||||
@@ -1288,6 +1288,49 @@ export class WebServer extends EventEmitter {
|
||||
// actual on/off. We expose `__codemanGestureAvailable` so the settings UI can
|
||||
// show the toggle only when the feature is available, and inject the bundle
|
||||
// (served same-origin from /gesture/, so 'self' covers it) only when enabled.
|
||||
// Tool availability (#200/#201): the welcome-screen run buttons, the run-mode
|
||||
// dropdown entries and the App Settings "Codex CLI" tab are all offers that a
|
||||
// box without the binary cannot keep — picking one spawns a session that
|
||||
// errors out immediately. One object answers all three.
|
||||
//
|
||||
// INJECTED, not fetched per surface. The `/api/<cli>/status` routes exist and
|
||||
// stay (they mirror each other and are a fine API surface), but as the source
|
||||
// for UI gating they buy nothing: every resolver memoizes its PATH probe on
|
||||
// the server, so a fetch is exactly as stale as an injected value, while
|
||||
// costing a round trip each time the dropdown opens and leaving the welcome
|
||||
// buttons to flicker in after paint. Installing a CLI later needs a server
|
||||
// restart either way. Memoized probes also make this cheap per render.
|
||||
//
|
||||
// Solo popups skip it: no settings modal, no welcome screen, no run menu.
|
||||
if (!soloSessionId) {
|
||||
const [
|
||||
{ isClaudeAvailable },
|
||||
{ isOpenCodeAvailable },
|
||||
{ isCodexAvailable },
|
||||
{ isGeminiAvailable },
|
||||
{ isAntigravityAvailable },
|
||||
{ isCloudflaredAvailable },
|
||||
] = await Promise.all([
|
||||
import('../utils/claude-cli-resolver.js'),
|
||||
import('../utils/opencode-cli-resolver.js'),
|
||||
import('../utils/codex-cli-resolver.js'),
|
||||
import('../utils/gemini-cli-resolver.js'),
|
||||
import('../utils/antigravity-cli-resolver.js'),
|
||||
import('../utils/cloudflared-resolver.js'),
|
||||
]);
|
||||
const available = {
|
||||
claude: isClaudeAvailable(),
|
||||
opencode: isOpenCodeAvailable(),
|
||||
codex: isCodexAvailable(),
|
||||
gemini: isGeminiAvailable(),
|
||||
antigravity: isAntigravityAvailable(),
|
||||
cloudflared: isCloudflaredAvailable(),
|
||||
};
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
`<script>window.__codemanCliAvailable=${JSON.stringify(available)};</script>\n</head>`
|
||||
);
|
||||
}
|
||||
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
|
||||
html = html.replace('</head>', `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
|
||||
if (settings.gestureControlEnabled === true) {
|
||||
|
||||
@@ -118,6 +118,8 @@ describe('Antigravity mode gates', () => {
|
||||
|
||||
it('has docker/remote default commands', () => {
|
||||
expect(defaultDockerCommandForMode('antigravity')).toBe('exec agy');
|
||||
expect(defaultRemoteCommandForMode('antigravity')).toBe('exec agy');
|
||||
// Routed through an interactive login shell so per-user PATH entries resolve —
|
||||
// same fix as the other remote agent CLIs (see defaultRemoteCommandForMode).
|
||||
expect(defaultRemoteCommandForMode('antigravity')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'agy\'');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -245,6 +245,119 @@ describe('Inline rename input', () => {
|
||||
expect(result.threw).toBe(false);
|
||||
});
|
||||
|
||||
it('Render guard: _renderSessionTabsImmediate() does not destroy an open rename input', async () => {
|
||||
await resetState();
|
||||
|
||||
// The debounced tab render is scheduled by renderSessionTabs() but EXECUTED by
|
||||
// _renderSessionTabsImmediate(). A render queued just before the rename opened
|
||||
// still fires ~100ms later and lands in the executor directly, so the guard has
|
||||
// to live there too, otherwise the incremental branch rewrites .tab-name's
|
||||
// innerHTML and the user's half-typed description is lost.
|
||||
//
|
||||
// The tab MUST live inside the real #sessionTabs container and be the only
|
||||
// session in app.sessions: the renderer walks that container, so a synthetic
|
||||
// node parked on <body> would make this test pass with the guard removed.
|
||||
const result = await page.evaluate(() => {
|
||||
const app = (
|
||||
window as unknown as {
|
||||
app: {
|
||||
sessions: Map<string, { id: string; name: string; status: string }>;
|
||||
sessionOrder: string[];
|
||||
startInlineRename: (id: string) => void;
|
||||
_renderSessionTabsImmediate: () => void;
|
||||
_activeRename: unknown;
|
||||
};
|
||||
}
|
||||
).app;
|
||||
const id = 'render-race';
|
||||
app.sessions.set(id, { id, name: 'w9-case', status: 'idle' });
|
||||
app.sessionOrder = [id];
|
||||
|
||||
const container = document.getElementById('sessionTabs') as HTMLElement;
|
||||
const tab = document.createElement('div');
|
||||
tab.setAttribute('data-test-tab', '1');
|
||||
tab.className = 'session-tab';
|
||||
tab.dataset.id = id;
|
||||
tab.innerHTML =
|
||||
'<span class="tab-status idle"></span><span class="tab-info"><span class="tab-name-row">' +
|
||||
`<span class="tab-name" data-session-id="${id}">w9-case</span>` +
|
||||
'</span></span>';
|
||||
container.appendChild(tab);
|
||||
|
||||
app.startInlineRename(id);
|
||||
const input = document.querySelector('input.tab-rename-input') as HTMLInputElement | null;
|
||||
if (!input) return { opened: false };
|
||||
input.value = 'half-typed';
|
||||
|
||||
// Exactly what a debounce timer queued before the rename would do.
|
||||
app._renderSessionTabsImmediate();
|
||||
|
||||
const after = document.querySelector('input.tab-rename-input') as HTMLInputElement | null;
|
||||
return {
|
||||
opened: true,
|
||||
stillInDom: !!after && document.body.contains(after),
|
||||
value: after?.value ?? null,
|
||||
renameStillActive: !!app._activeRename,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.opened).toBe(true);
|
||||
expect(result.stillInDom).toBe(true);
|
||||
expect(result.value).toBe('half-typed');
|
||||
expect(result.renameStillActive).toBe(true);
|
||||
});
|
||||
|
||||
it('Modal: closeSessionOptions() commits the Session Name field before clearing the id', async () => {
|
||||
await resetState();
|
||||
|
||||
// Every autosave handler in the session-options modal bails on a null
|
||||
// editingSessionId, and hiding the modal blurs the focused input. If the id is
|
||||
// cleared first, the blur-driven save is dropped and the typed name vanishes,
|
||||
// which is what Escape and backdrop-click used to do.
|
||||
const result = await page.evaluate(async () => {
|
||||
const app = (
|
||||
window as unknown as {
|
||||
app: {
|
||||
editingSessionId: string | null;
|
||||
sessions: Map<string, { id: string; name: string }>;
|
||||
closeSessionOptions: () => void;
|
||||
};
|
||||
}
|
||||
).app;
|
||||
app.sessions.set('modal-id', { id: 'modal-id', name: 'w9-case' });
|
||||
app.editingSessionId = 'modal-id';
|
||||
|
||||
const nameInput = document.getElementById('modalSessionName') as HTMLInputElement;
|
||||
const modal = document.getElementById('sessionOptionsModal') as HTMLElement;
|
||||
modal.classList.add('active');
|
||||
// The Session Name field lives on the modal's Context tab, which is hidden
|
||||
// until selected: a hidden input cannot take focus.
|
||||
document.getElementById('context-tab')?.classList.remove('hidden');
|
||||
nameInput.value = 'mydesc';
|
||||
nameInput.focus();
|
||||
const wasFocused = document.activeElement === nameInput;
|
||||
|
||||
let putBody: string | null = null;
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input).includes('/api/sessions/modal-id/name')) putBody = String(init?.body ?? '');
|
||||
return new Response('{"success":true}', { status: 200 });
|
||||
}) as typeof window.fetch;
|
||||
|
||||
app.closeSessionOptions();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
window.fetch = origFetch;
|
||||
modal.classList.remove('active');
|
||||
|
||||
return { wasFocused, putBody, editingAfter: app.editingSessionId };
|
||||
});
|
||||
|
||||
expect(result.wasFocused).toBe(true);
|
||||
// Prefixed session: the suffix the user typed is appended to the w9-case prefix.
|
||||
expect(result.putBody).toContain('w9-case: mydesc');
|
||||
expect(result.editingAfter).toBe(null);
|
||||
});
|
||||
|
||||
it('Re-entry: starting rename while one is active aborts the previous one', async () => {
|
||||
await resetState();
|
||||
expect(await startRename('first-id', 'First')).toBe(true);
|
||||
|
||||
@@ -55,10 +55,15 @@ describe('remote-hosts domain', () => {
|
||||
});
|
||||
|
||||
it('returns safe mode defaults and remote display values', () => {
|
||||
expect(defaultRemoteCommandForMode('shell')).toBe('exec bash -l');
|
||||
expect(defaultRemoteCommandForMode('codex')).toBe('exec codex');
|
||||
expect(defaultRemoteCommandForMode('shell')).toBe('exec "${SHELL:-/bin/sh}" -i -l');
|
||||
// Routed through an interactive login shell so per-user PATH entries (e.g.
|
||||
// ~/.local/bin, ~/.opencode/bin) resolve — a bare `exec codex` sees only
|
||||
// sshd's minimal default PATH and fails with "command not found".
|
||||
expect(defaultRemoteCommandForMode('codex')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'codex\'');
|
||||
// Mirrors the local claude default so the remote agent runs non-interactively.
|
||||
expect(defaultRemoteCommandForMode('claude')).toBe('exec claude --dangerously-skip-permissions');
|
||||
expect(defaultRemoteCommandForMode('claude')).toBe(
|
||||
'exec "${SHELL:-/bin/sh}" -i -l -c \'claude --dangerously-skip-permissions\''
|
||||
);
|
||||
expect(remoteSshTarget({ id: 'h1', label: 'H1', host: 'box.local', username: 'aamer' })).toBe('aamer@box.local');
|
||||
expect(remoteDisplayPath({ username: 'aamer', host: 'box.local', path: '/opt/work' })).toBe(
|
||||
'aamer@box.local:/opt/work'
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
|
||||
const sh = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
const remoteName = `codeman-ssh-${SESSION_ID.slice(0, 8)}`;
|
||||
const path = sh('/home/ubuntu/work');
|
||||
const paneCommand = `cd ${path} && exec bash -l`;
|
||||
const paneCommand = `cd ${path} && exec "\${SHELL:-/bin/sh}" -i -l`;
|
||||
const tmuxInvocation = [
|
||||
`tmux -L codeman-remote new-session -A -s ${remoteName} -c ${path} ${sh(paneCommand)}`,
|
||||
`set -t ${remoteName} status off`,
|
||||
@@ -159,6 +159,10 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
|
||||
'set -s escape-time 0',
|
||||
// COD-106 — shared/collaborative sizing, per-session scoped (never -g).
|
||||
`set -t ${remoteName} window-size latest`,
|
||||
// #210 — keep a CRASHED pane for diagnosis. `failed` (not `on`, which would
|
||||
// also strand a pane after a clean `exit`), and LAST because tmux aborts the
|
||||
// remaining commands of a `\;` chain on error and `failed` needs tmux >= 3.2.
|
||||
`set -t ${remoteName} remain-on-exit failed`,
|
||||
].join(' \\; ');
|
||||
// Connection args (with the default -o ConnectTimeout=10) sit after -t.
|
||||
const expected = `ssh -o BatchMode=yes -t -o ConnectTimeout=10 ${remoteSshTarget(baseRemote)} ${sh(tmuxInvocation)}`;
|
||||
|
||||
@@ -12,6 +12,40 @@
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { isClaudeAvailable } from '../src/utils/claude-cli-resolver.js';
|
||||
import { isOpenCodeAvailable } from '../src/utils/opencode-cli-resolver.js';
|
||||
import { isCodexAvailable } from '../src/utils/codex-cli-resolver.js';
|
||||
import { isGeminiAvailable } from '../src/utils/gemini-cli-resolver.js';
|
||||
import { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js';
|
||||
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
|
||||
|
||||
// renderIndexHtml probes the real PATH for every CLI, which would make the
|
||||
// assertions below depend on whatever happens to be installed on the machine
|
||||
// running the suite. Default them all to "not installed" and opt in per test.
|
||||
vi.mock('../src/utils/claude-cli-resolver.js', () => ({
|
||||
isClaudeAvailable: vi.fn(() => false),
|
||||
findClaudeDir: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock('../src/utils/opencode-cli-resolver.js', () => ({
|
||||
isOpenCodeAvailable: vi.fn(() => false),
|
||||
resolveOpenCodeDir: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock('../src/utils/codex-cli-resolver.js', () => ({
|
||||
isCodexAvailable: vi.fn(() => false),
|
||||
resolveCodexDir: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock('../src/utils/gemini-cli-resolver.js', () => ({
|
||||
isGeminiAvailable: vi.fn(() => false),
|
||||
resolveGeminiDir: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock('../src/utils/antigravity-cli-resolver.js', () => ({
|
||||
isAntigravityAvailable: vi.fn(() => false),
|
||||
resolveAntigravityDir: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock('../src/utils/cloudflared-resolver.js', () => ({
|
||||
isCloudflaredAvailable: vi.fn(() => false),
|
||||
resolveCloudflaredPath: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
const TEMPLATE = [
|
||||
'<head>',
|
||||
@@ -86,6 +120,55 @@ describe('WebServer.renderIndexHtml', () => {
|
||||
expect(html).toContain('gesture-codeman.js');
|
||||
});
|
||||
|
||||
it('reports every tool the welcome buttons, run menu and Codex tab gate on', async () => {
|
||||
vi.mocked(isClaudeAvailable).mockReturnValue(true);
|
||||
vi.mocked(isOpenCodeAvailable).mockReturnValue(false);
|
||||
vi.mocked(isCodexAvailable).mockReturnValue(true);
|
||||
vi.mocked(isGeminiAvailable).mockReturnValue(false);
|
||||
vi.mocked(isAntigravityAvailable).mockReturnValue(false);
|
||||
vi.mocked(isCloudflaredAvailable).mockReturnValue(true);
|
||||
const { server } = makeServer({});
|
||||
const html = await render(server);
|
||||
const flags = JSON.parse(html.match(/window\.__codemanCliAvailable=(\{.*?\});/)![1]);
|
||||
// Every key must be PRESENT, not merely truthy where installed: the client
|
||||
// treats a missing key as available, so a dropped key silently un-gates.
|
||||
expect(flags).toEqual({
|
||||
claude: true,
|
||||
opencode: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
antigravity: false,
|
||||
cloudflared: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('still emits the object when nothing at all is installed', async () => {
|
||||
// The all-false case is the one that matters most and the easiest to get
|
||||
// wrong by only injecting when something resolves.
|
||||
for (const probe of [
|
||||
isClaudeAvailable,
|
||||
isOpenCodeAvailable,
|
||||
isCodexAvailable,
|
||||
isGeminiAvailable,
|
||||
isAntigravityAvailable,
|
||||
isCloudflaredAvailable,
|
||||
]) {
|
||||
vi.mocked(probe).mockReturnValue(false);
|
||||
}
|
||||
const { server } = makeServer({});
|
||||
const html = await render(server);
|
||||
expect(html).toContain('window.__codemanCliAvailable=');
|
||||
const flags = JSON.parse(html.match(/window\.__codemanCliAvailable=(\{.*?\});/)![1]);
|
||||
expect(Object.values(flags).every((v) => v === false)).toBe(true);
|
||||
});
|
||||
|
||||
it('skips the probe for a solo window, which has no welcome screen or run menu', async () => {
|
||||
vi.mocked(isCodexAvailable).mockReturnValue(true);
|
||||
const { server } = makeServer({});
|
||||
const html = await render(server, 'sess-123');
|
||||
expect(html).not.toContain('__codemanCliAvailable');
|
||||
});
|
||||
|
||||
it('does not expose gesture at all when CODEMAN_GESTURE is unset', async () => {
|
||||
delete process.env.CODEMAN_GESTURE;
|
||||
const { server } = makeServer({ gestureControlEnabled: true });
|
||||
|
||||
@@ -18,7 +18,7 @@ import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { join } from 'node:path';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
@@ -1288,6 +1288,68 @@ describe('session-routes', () => {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.sessions.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
it('decodes a dotdir working directory (e.g. ~/.codeman) instead of falling back to $HOME', async () => {
|
||||
// Claude Code's project-key encoding maps both '/' and '.' to '-', so
|
||||
// "/home/x/.dotcase" and "/home/x/dotcase" collapse to the same-looking
|
||||
// dash run except for a doubled dash. decodeProjectKey() must still
|
||||
// recover the real (dotdir) path rather than silently falling back to
|
||||
// bare $HOME (COD bug: 2026-08-01, ~/.codeman resumed sessions got
|
||||
// workingDir "/home/timkjr" instead of "/home/timkjr/.codeman").
|
||||
const home = process.env.HOME as string;
|
||||
const realDir = join(home, '.dotcase');
|
||||
await mkdir(realDir, { recursive: true });
|
||||
|
||||
const projectKey = realDir.replace(/\//g, '-').replace(/\./g, '-');
|
||||
const projDir = join(home, '.claude', 'projects', projectKey);
|
||||
await mkdir(projDir, { recursive: true });
|
||||
|
||||
const sessionId = '12345678-1234-1234-1234-123456789012';
|
||||
const transcriptLine = JSON.stringify({ type: 'user', message: { role: 'user', content: 'hello world' } }) + '\n';
|
||||
// scanProjectDir skips files under 4000 bytes.
|
||||
const padding = '#'.repeat(4200 - transcriptLine.length);
|
||||
await writeFile(join(projDir, `${sessionId}.jsonl`), transcriptLine + padding);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/history/sessions?projectKey=${projectKey}`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
const row = body.data.sessions.find((s: { sessionId: string }) => s.sessionId === sessionId);
|
||||
expect(row).toBeDefined();
|
||||
expect(row.workingDir).toBe(realDir);
|
||||
expect(row.workingDir).not.toBe(home);
|
||||
});
|
||||
|
||||
it('prefers the dotdir over a same-named non-dot sibling, and never emits a "//" path', async () => {
|
||||
// A doubled dash also lets the decoder read the empty split segment as a
|
||||
// directory NAME. `isDir(current + '/' + '')` stats `current + '/'`, which
|
||||
// always succeeds, so `~/.sib` + `~/sib` both existing used to resolve to
|
||||
// "/home/x//sib": the wrong directory, spelled with a double slash that
|
||||
// then fails every string comparison against session.workingDir. The empty
|
||||
// candidate is never a real path component, so it is skipped outright,
|
||||
// which is also what lets the dotdir branch below it run at all.
|
||||
const home = process.env.HOME as string;
|
||||
const dotDir = join(home, '.sib');
|
||||
await mkdir(dotDir, { recursive: true });
|
||||
await mkdir(join(home, 'sib'), { recursive: true });
|
||||
|
||||
const projectKey = dotDir.replace(/\//g, '-').replace(/\./g, '-');
|
||||
const projDir = join(home, '.claude', 'projects', projectKey);
|
||||
await mkdir(projDir, { recursive: true });
|
||||
|
||||
const sessionId = '22222222-2222-2222-2222-222222222222';
|
||||
const line = JSON.stringify({ type: 'user', message: { role: 'user', content: 'hello world' } }) + '\n';
|
||||
await writeFile(join(projDir, `${sessionId}.jsonl`), line + '#'.repeat(4200 - line.length));
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/history/sessions?projectKey=${projectKey}` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const row = JSON.parse(res.body).data.sessions.find((s: { sessionId: string }) => s.sessionId === sessionId);
|
||||
expect(row).toBeDefined();
|
||||
expect(row.workingDir).toBe(dotDir);
|
||||
expect(row.workingDir).not.toContain('//');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions (with resumeSessionId) ==========
|
||||
|
||||
+252
-1
@@ -84,6 +84,101 @@ describe('run mode UI', () => {
|
||||
});
|
||||
|
||||
describe('Run launch synchronization', () => {
|
||||
it('keeps launch progress out of an active session terminal', () => {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: () => null },
|
||||
console,
|
||||
});
|
||||
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.activeSessionId = 'existing-session';
|
||||
app.terminal = {
|
||||
clear: vi.fn(),
|
||||
writeln: vi.fn(),
|
||||
};
|
||||
app.showToast = vi.fn();
|
||||
|
||||
const ownsTerminal = app._beginSessionLaunchStatus('Starting Codex session', '1;32');
|
||||
app._appendSessionLaunchStatus(ownsTerminal, 'Creating session');
|
||||
app._reportSessionLaunchError(ownsTerminal, 'Launch failed');
|
||||
|
||||
expect(ownsTerminal).toBe(false);
|
||||
expect(app.terminal.clear).not.toHaveBeenCalled();
|
||||
expect(app.terminal.writeln).not.toHaveBeenCalled();
|
||||
expect(app.showToast).toHaveBeenNthCalledWith(1, 'Starting Codex session', 'info');
|
||||
expect(app.showToast).toHaveBeenNthCalledWith(2, 'Launch failed', 'error');
|
||||
});
|
||||
|
||||
it('still renders launch progress in the terminal on the session-less home screen', () => {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: () => null },
|
||||
console,
|
||||
});
|
||||
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.activeSessionId = null; // home screen: nothing else owns the terminal
|
||||
app.terminal = { clear: vi.fn(), writeln: vi.fn() };
|
||||
app.showToast = vi.fn();
|
||||
|
||||
const ownsTerminal = app._beginSessionLaunchStatus('Starting Codex session', '1;32');
|
||||
app._appendSessionLaunchStatus(ownsTerminal, 'Creating session');
|
||||
app._reportSessionLaunchError(ownsTerminal, 'Launch failed');
|
||||
|
||||
expect(ownsTerminal).toBe(true);
|
||||
expect(app.terminal.clear).toHaveBeenCalledTimes(1);
|
||||
expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Starting Codex session');
|
||||
expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Creating session');
|
||||
expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Error: Launch failed');
|
||||
expect(app.showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* Static guard over session-ui.js itself. The helpers above can be perfectly
|
||||
* correct while a run*() entry point still writes to the shared xterm
|
||||
* directly, which is the actual bug: a launch started while another session
|
||||
* is active wipes that session's terminal, and _cleanupPreviousSession()
|
||||
* then serializes the wiped view into its restore snapshot. Asserting on the
|
||||
* helpers alone cannot see that, so pin the call sites here. This also
|
||||
* covers run modes added later, which is how runAntigravity was caught.
|
||||
*/
|
||||
it('routes every run mode through the ownership helpers, never the terminal directly', () => {
|
||||
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
|
||||
// Methods live in one Object.assign(prototype, {...}) block at a fixed
|
||||
// 2-space indent, so `\n },` reliably closes the one we are inside.
|
||||
const bodies = new Map<string, string>();
|
||||
const header = /^ {2}async (run[A-Za-z]*)\(\) \{$/gm;
|
||||
for (let m = header.exec(src); m; m = header.exec(src)) {
|
||||
const start = m.index + m[0].length;
|
||||
const end = src.indexOf('\n },', start);
|
||||
expect(end, `could not find the end of ${m[1]}()`).toBeGreaterThan(start);
|
||||
bodies.set(m[1], src.slice(start, end));
|
||||
}
|
||||
|
||||
// Fail loudly if the scan matched nothing: a silently empty scan would make
|
||||
// every assertion below vacuously true.
|
||||
expect([...bodies.keys()]).toEqual(
|
||||
expect.arrayContaining(['runClaude', 'runShell', 'runOpenCode', 'runCodex', 'runGemini', 'runAntigravity'])
|
||||
);
|
||||
|
||||
for (const [name, body] of bodies) {
|
||||
expect(body, `${name}() must not clear a terminal it may not own`).not.toContain('this.terminal.clear(');
|
||||
expect(body, `${name}() must not write launch status straight to the terminal`).not.toContain(
|
||||
'this.terminal.writeln('
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('coalesces overlapping Run activations and disables the button while the request is active', async () => {
|
||||
const runBtn = {
|
||||
disabled: false,
|
||||
@@ -188,14 +283,169 @@ describe('Codex quick start settings', () => {
|
||||
/<div class="modal-tab-content hidden" id="settings-claude">([\s\S]*?)<!-- Codex CLI Tab -->/
|
||||
);
|
||||
expect(claudeTab?.[1]).not.toContain('appSettingsCodexDangerouslyBypassApprovals');
|
||||
expect(claudeTab?.[1]).not.toContain('appSettingsCodexAnimations');
|
||||
|
||||
const codexTab = html.match(
|
||||
/<div class="modal-tab-content hidden" id="settings-codex">([\s\S]*?)<\/div>\s*<!-- Models Tab -->/
|
||||
);
|
||||
expect(codexTab?.[1]).toContain('appSettingsCodexDangerouslyBypassApprovals');
|
||||
expect(codexTab?.[1]).toContain('appSettingsCodexAnimations');
|
||||
expect(codexTab?.[1]).not.toContain('appSettingsCodexRenderMode');
|
||||
});
|
||||
|
||||
describe('Codex CLI tab visibility', () => {
|
||||
// Both settings on the tab are handed to `codex` at launch, so on an instance
|
||||
// where the binary does not resolve the tab is a promise nothing can keep.
|
||||
// renderIndexHtml injects window.__codemanCliAvailable; this pins the client
|
||||
// half. Coupled test: it drives the REAL settings-ui.js against a stub button,
|
||||
// so deleting the call in openAppSettings() is what it is meant to catch.
|
||||
function loadSettingsUi(codexAvailable: boolean | undefined) {
|
||||
const codexTabBtn = { dataset: { tab: 'settings-codex' }, style: { display: 'PRISTINE' } };
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context: any = vm.createContext({
|
||||
CodemanApp,
|
||||
MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false },
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: {
|
||||
getElementById: () => null,
|
||||
querySelector: (sel: string) => (sel.includes('[data-tab="settings-codex"]') ? codexTabBtn : null),
|
||||
},
|
||||
console,
|
||||
});
|
||||
context.window = context;
|
||||
if (codexAvailable !== undefined) context.__codemanCliAvailable = { codex: codexAvailable };
|
||||
const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
|
||||
vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
|
||||
return { app: new (CodemanApp as any)(), codexTabBtn };
|
||||
}
|
||||
|
||||
it('hides the Codex tab when the codex binary is not available', () => {
|
||||
const { app, codexTabBtn } = loadSettingsUi(false);
|
||||
app._applyCodexSettingsVisibility();
|
||||
expect(codexTabBtn.style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('hides the Codex tab when the availability flag was never injected', () => {
|
||||
const { app, codexTabBtn } = loadSettingsUi(undefined);
|
||||
app._applyCodexSettingsVisibility();
|
||||
expect(codexTabBtn.style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('shows the Codex tab when codex is available', () => {
|
||||
const { app, codexTabBtn } = loadSettingsUi(true);
|
||||
app._applyCodexSettingsVisibility();
|
||||
expect(codexTabBtn.style.display).toBe('');
|
||||
});
|
||||
|
||||
it('applies the gating from openAppSettings, not just in isolation', () => {
|
||||
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
|
||||
const open = src.slice(src.indexOf('\n openAppSettings() {'));
|
||||
const body = open.slice(0, open.indexOf('\n },'));
|
||||
expect(body).toContain('_applyCodexSettingsVisibility()');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLI availability gating (#200/#201)', () => {
|
||||
// Drives the REAL settings-ui.js + session-ui.js against stub elements, so an
|
||||
// added run mode that nobody wires up here is what these are meant to catch.
|
||||
function loadUi(flags: Record<string, boolean> | undefined) {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const welcomeBtns: Record<string, { style: { display: string } }> = {};
|
||||
for (const id of ['welcomeClaudeBtn', 'welcomeOpencodeBtn', 'welcomeGeminiBtn', 'welcomeTunnelBtn']) {
|
||||
welcomeBtns[id] = { style: { display: 'PRISTINE' } };
|
||||
}
|
||||
const modeBtns: Record<string, { style: { display: string } }> = {};
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'shell']) {
|
||||
modeBtns[mode] = { style: { display: 'PRISTINE' } };
|
||||
}
|
||||
const menu = {
|
||||
querySelector: (sel: string) => {
|
||||
const m = sel.match(/data-mode="([^"]+)"/);
|
||||
return m ? (modeBtns[m[1]] ?? null) : null;
|
||||
},
|
||||
};
|
||||
const context: any = vm.createContext({
|
||||
CodemanApp,
|
||||
MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false },
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: (id: string) => welcomeBtns[id] ?? null, querySelector: () => null },
|
||||
console,
|
||||
});
|
||||
context.window = context;
|
||||
if (flags !== undefined) context.__codemanCliAvailable = flags;
|
||||
for (const file of ['settings-ui.js', 'session-ui.js']) {
|
||||
const src = readFileSync(resolve(import.meta.dirname, `../src/web/public/${file}`), 'utf8');
|
||||
vm.runInContext(src, context, { filename: file });
|
||||
}
|
||||
return { app: new (CodemanApp as any)(), welcomeBtns, modeBtns, menu };
|
||||
}
|
||||
|
||||
const ALL_OFF = {
|
||||
claude: false,
|
||||
opencode: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
antigravity: false,
|
||||
cloudflared: false,
|
||||
};
|
||||
|
||||
it('hides each welcome button whose tool is missing, including the tunnel', () => {
|
||||
const { app, welcomeBtns } = loadUi({ ...ALL_OFF, claude: true });
|
||||
app.applyWelcomeCliVisibility();
|
||||
expect(welcomeBtns.welcomeClaudeBtn.style.display).toBe('flex');
|
||||
expect(welcomeBtns.welcomeOpencodeBtn.style.display).toBe('none');
|
||||
expect(welcomeBtns.welcomeGeminiBtn.style.display).toBe('none');
|
||||
// #200 originally DELETED the tunnel button and its QR outright; it is gated
|
||||
// on cloudflared instead, so a box that has cloudflared keeps the feature.
|
||||
expect(welcomeBtns.welcomeTunnelBtn.style.display).toBe('none');
|
||||
|
||||
const withTunnel = loadUi({ ...ALL_OFF, cloudflared: true });
|
||||
withTunnel.app.applyWelcomeCliVisibility();
|
||||
expect(withTunnel.welcomeBtns.welcomeTunnelBtn.style.display).toBe('flex');
|
||||
});
|
||||
|
||||
it('gates every run mode in the dropdown, antigravity included, and never shell', () => {
|
||||
const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true });
|
||||
app._refreshRunModeAvailability(menu);
|
||||
expect(modeBtns.claude.style.display).toBe('flex');
|
||||
expect(modeBtns.antigravity.style.display).toBe('flex');
|
||||
expect(modeBtns.opencode.style.display).toBe('none');
|
||||
expect(modeBtns.codex.style.display).toBe('none');
|
||||
expect(modeBtns.gemini.style.display).toBe('none');
|
||||
// Shell needs no external CLI, and leaving it alone is what guarantees the
|
||||
// menu is never empty on a box with nothing installed.
|
||||
expect(modeBtns.shell.style.display).toBe('PRISTINE');
|
||||
});
|
||||
|
||||
it('gates every mode the run-mode menu actually offers', () => {
|
||||
// Catches a sixth run mode being added to index.html without being gated,
|
||||
// which is exactly how antigravity slipped past #201.
|
||||
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
|
||||
const menuHtml = html.slice(html.indexOf('id="runModeMenu"'));
|
||||
const offered = [...menuHtml.slice(0, menuHtml.indexOf('</div>')).matchAll(/data-mode="([^"]+)"/g)].map(
|
||||
(m) => m[1]
|
||||
);
|
||||
expect(offered).toContain('antigravity');
|
||||
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
// Anchor on the DEFINITION, not the earlier call site in toggleRunModeMenu.
|
||||
const fn = src.slice(src.indexOf('_refreshRunModeAvailability(menu) {'));
|
||||
const gated = fn.slice(0, fn.indexOf('\n },'));
|
||||
for (const mode of offered.filter((m) => m !== 'shell')) {
|
||||
expect(gated).toContain(`'${mode}'`);
|
||||
}
|
||||
});
|
||||
|
||||
it('shows everything when the flags were never injected', () => {
|
||||
// A cached page from a build without the injection, or a solo popup. Hiding
|
||||
// every run button on a doubt would leave a working install nothing to click.
|
||||
const { app, welcomeBtns, modeBtns, menu } = loadUi(undefined);
|
||||
app.applyWelcomeCliVisibility();
|
||||
app._refreshRunModeAvailability(menu);
|
||||
expect(welcomeBtns.welcomeClaudeBtn.style.display).toBe('flex');
|
||||
expect(modeBtns.gemini.style.display).toBe('flex');
|
||||
});
|
||||
});
|
||||
|
||||
it('passes global Codex settings into quick-start config for new sessions', async () => {
|
||||
const elements: Record<string, any> = {
|
||||
quickStartCase: { value: 'codex-case' },
|
||||
@@ -232,6 +482,7 @@ describe('Codex quick start settings', () => {
|
||||
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
|
||||
app.loadAppSettingsFromStorage = () => ({
|
||||
codexDangerouslyBypassApprovals: true,
|
||||
codexAnimationsEnabled: false,
|
||||
});
|
||||
app.getCaseSettings = () => ({});
|
||||
app.buildEnvOverrides = () => ({});
|
||||
@@ -250,7 +501,7 @@ describe('Codex quick start settings', () => {
|
||||
mode: 'codex',
|
||||
// tabs follow the w<n>-<case> naming convention (quick-start would otherwise auto-name codeman-<id>)
|
||||
sessionName: 'w1-codex-case',
|
||||
codexConfig: { dangerouslyBypassApprovals: true, renderMode: 'hybrid' },
|
||||
codexConfig: { dangerouslyBypassApprovals: true, animations: false, renderMode: 'hybrid' },
|
||||
});
|
||||
expect(selected).toEqual(['sess-1']);
|
||||
});
|
||||
|
||||
@@ -96,8 +96,16 @@ describe('WebServer index.html <title> templating (#82)', () => {
|
||||
|
||||
it('only substitutes the <title> tag — the rest of the template is identical (modulo asset cache-busting)', async () => {
|
||||
// renderIndexHtml also appends ?v=<mtime> cache-bust params to same-origin
|
||||
// .js/.css refs; strip them so the title remains the only other change.
|
||||
const html = (await render('laptop')).replace(/(\.(?:js|css))\?v=[^"]*/g, '$1');
|
||||
// .js/.css refs, and injects the CLI-availability flags before </head>; strip
|
||||
// both so the title remains the only other change.
|
||||
//
|
||||
// The flag strip is what keeps this test environment-independent. It used to
|
||||
// pass here by luck: the availability script was injected only where a CLI
|
||||
// resolved, so the assertion held on a machine with none installed and would
|
||||
// have failed on a developer's box that had them.
|
||||
const html = (await render('laptop'))
|
||||
.replace(/(\.(?:js|css))\?v=[^"]*/g, '$1')
|
||||
.replace(/<script>window\.__codemanCliAvailable=\{.*?\};<\/script>\n/, '');
|
||||
const beforeTitle = rawTemplate.split('<title>Codeman</title>')[0];
|
||||
const afterTitle = rawTemplate.split('<title>Codeman</title>')[1];
|
||||
expect(html.startsWith(beforeTitle)).toBe(true);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Regression tests for issue #208 — "Plain shell PTY exits with code 1 after
|
||||
* successful tmux creation in Docker".
|
||||
*
|
||||
* The shell-mode pane command used to be the literal string `$SHELL`. It ends up
|
||||
* inside the `bash -c "…"` argument of the respawn-pane line, which execSync runs
|
||||
* through `/bin/sh -c`, so it was expanded by the SERVER process's shell against
|
||||
* the SERVER process's env. Containers (and system-level systemd units) do not set
|
||||
* SHELL, so it expanded to nothing and the pane command ended in a dangling `&&`:
|
||||
*
|
||||
* bash: -c: line 1: syntax error: unexpected end of file
|
||||
*
|
||||
* These tests pin the resolver's guarantees and assert that a shell launch command
|
||||
* survives the outer `sh -c` layer with an unset SHELL.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { buildSpawnCommand } from '../src/tmux-manager.js';
|
||||
import { loginShellArgs, resolveLocalShell } from '../src/utils/shell-resolver.js';
|
||||
|
||||
describe('resolveLocalShell', () => {
|
||||
const originalShell = process.env.SHELL;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalShell === undefined) delete process.env.SHELL;
|
||||
else process.env.SHELL = originalShell;
|
||||
});
|
||||
|
||||
it('returns an absolute executable path when SHELL is unset (container case)', () => {
|
||||
delete process.env.SHELL;
|
||||
const shell = resolveLocalShell();
|
||||
expect(shell).not.toBe('');
|
||||
expect(shell.startsWith('/')).toBe(true);
|
||||
// Proves the resolved path is really launchable, not just a plausible string.
|
||||
expect(execFileSync(shell, ['-c', 'echo ok'], { encoding: 'utf8' }).trim()).toBe('ok');
|
||||
});
|
||||
|
||||
it('returns an absolute executable path when SHELL is empty or whitespace', () => {
|
||||
for (const value of ['', ' ']) {
|
||||
process.env.SHELL = value;
|
||||
const shell = resolveLocalShell();
|
||||
expect(shell.startsWith('/')).toBe(true);
|
||||
expect(execFileSync(shell, ['-c', 'echo ok'], { encoding: 'utf8' }).trim()).toBe('ok');
|
||||
}
|
||||
});
|
||||
|
||||
it('honors a valid $SHELL', () => {
|
||||
process.env.SHELL = '/bin/sh';
|
||||
expect(resolveLocalShell()).toBe('/bin/sh');
|
||||
});
|
||||
|
||||
it('ignores a $SHELL that does not exist', () => {
|
||||
process.env.SHELL = '/nonexistent/shell-that-is-not-here';
|
||||
const shell = resolveLocalShell();
|
||||
expect(shell).not.toBe('/nonexistent/shell-that-is-not-here');
|
||||
expect(shell.startsWith('/')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores a relative $SHELL (never emits a bare word into the launch command)', () => {
|
||||
process.env.SHELL = 'bash';
|
||||
expect(resolveLocalShell().startsWith('/')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores nologin-style stubs that would exit instantly', () => {
|
||||
process.env.SHELL = '/usr/sbin/nologin';
|
||||
expect(resolveLocalShell()).not.toContain('nologin');
|
||||
process.env.SHELL = '/bin/false';
|
||||
expect(resolveLocalShell()).not.toBe('/bin/false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loginShellArgs (#209 login flags, allowlisted)', () => {
|
||||
it('asks for a login shell on the POSIX-family shells that accept the flags', () => {
|
||||
for (const shell of ['/bin/sh', '/bin/bash', '/bin/dash', '/usr/bin/zsh', '/usr/local/bin/fish', '/bin/ksh']) {
|
||||
expect(loginShellArgs(shell)).toBe(' -i -l');
|
||||
}
|
||||
});
|
||||
|
||||
it('adds nothing for shells that take neither flag, so the pane cannot die on arrival', () => {
|
||||
// The shell path can come from the passwd entry, which is user data and can
|
||||
// name anything. A shell that rejects an unknown flag exits immediately —
|
||||
// indistinguishable from the #208 dead-pane-on-arrival this module prevents.
|
||||
// csh/tcsh are here too: tcsh honors -l only when it is the ONLY flag.
|
||||
for (const shell of ['/usr/bin/nu', '/usr/bin/elvish', '/usr/bin/xonsh', '/bin/tcsh', '/bin/csh']) {
|
||||
expect(loginShellArgs(shell)).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('really launches for every allowlisted shell present on this machine', () => {
|
||||
// The whole point of the allowlist is that the flags are ACCEPTED, so prove it
|
||||
// against the real binaries rather than trusting the set.
|
||||
for (const shell of ['/bin/sh', '/bin/bash', '/bin/dash', '/usr/bin/zsh', '/bin/ksh']) {
|
||||
let exists = true;
|
||||
try {
|
||||
execFileSync('/bin/sh', ['-c', `test -x ${shell}`]);
|
||||
} catch {
|
||||
exists = false;
|
||||
}
|
||||
if (!exists) continue;
|
||||
const out = execFileSync('/bin/sh', ['-c', `${shell} -i -l -c 'echo ok' 2>/dev/null`], { encoding: 'utf8' });
|
||||
expect(out).toContain('ok');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('shell-mode spawn command (issue #208)', () => {
|
||||
const originalShell = process.env.SHELL;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.SHELL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalShell === undefined) delete process.env.SHELL;
|
||||
else process.env.SHELL = originalShell;
|
||||
});
|
||||
|
||||
it('never emits an unexpanded $SHELL into the pane command', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'shell', sessionId: 'abc123de-0000-0000-0000-000000000000' });
|
||||
expect(cmd).not.toContain('$SHELL');
|
||||
expect(cmd.trim()).not.toBe('');
|
||||
});
|
||||
|
||||
it('launches a LOGIN shell, matching what tmux does for a pane with no default-command', () => {
|
||||
// A tmux pane already hands the shell a tty, so it is interactive either way
|
||||
// (`$-` contains `i` for a bare /bin/bash in a pane, which is why ~/.bashrc has
|
||||
// always been sourced). `-l` is the flag that changes anything: it is what
|
||||
// picks up /etc/profile and /etc/profile.d/*, which a systemd --user service
|
||||
// never sourced, so its minimal PATH is what every pane used to inherit.
|
||||
const cmd = buildSpawnCommand({ mode: 'shell', sessionId: 'abc123de-0000-0000-0000-000000000000' });
|
||||
expect(cmd.trim().endsWith('-i -l')).toBe(true);
|
||||
});
|
||||
|
||||
it('produces a launch command that parses after the outer sh -c expansion layer', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'shell', sessionId: 'abc123de-0000-0000-0000-000000000000' });
|
||||
// Mirrors tmux-manager: `… bash -c ${JSON.stringify(launchCmd)}` handed to `sh -c`.
|
||||
const launchCmd = `cd ${JSON.stringify('/tmp')} && export CODEMAN_MUX=1 && ${cmd}`;
|
||||
const outer = `bash -n -c ${JSON.stringify(launchCmd)}`;
|
||||
|
||||
// `bash -n` parses without executing: exits 0 on the fix, 2 with the dangling `&&`.
|
||||
const result = execFileSync('/bin/sh', ['-c', `${outer}; echo "rc=$?"`], { encoding: 'utf8' });
|
||||
expect(result).toContain('rc=0');
|
||||
expect(result).not.toContain('unexpected end of file');
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
TmuxManager,
|
||||
buildCodexCommand,
|
||||
buildRemoteKillCommand,
|
||||
buildRemoteLaunchCommand,
|
||||
formatPaneSnapshot,
|
||||
@@ -98,6 +99,14 @@ describe('TmuxManager (unit)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Codex command builder', () => {
|
||||
it('controls decorative TUI animation through Codex config', () => {
|
||||
expect(buildCodexCommand({ animations: false })).toBe('codex --config tui.animations=false');
|
||||
expect(buildCodexCommand({ animations: true })).toBe('codex --config tui.animations=true');
|
||||
expect(buildCodexCommand()).toBe('codex');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote launch command builder', () => {
|
||||
it('wraps codex command overrides in ssh with remote tmux launch', () => {
|
||||
const command = buildRemoteLaunchCommand({
|
||||
@@ -137,7 +146,16 @@ describe('TmuxManager (unit)', () => {
|
||||
sessionId: 'abc123def456',
|
||||
});
|
||||
|
||||
expect(command).toContain('exec bash -l');
|
||||
expect(command).toContain('exec "${SHELL:-/bin/sh}" -i -l');
|
||||
// `failed`, not `on`: `on` also keeps the pane after a CLEAN exit, so typing
|
||||
// `exit` in a remote shell strands a dead pane that the next launch's `-A`
|
||||
// reattaches to instead of starting a shell.
|
||||
expect(command).toContain('remain-on-exit failed');
|
||||
expect(command).not.toContain('remain-on-exit on');
|
||||
// Last in the chain: tmux aborts the rest of a `\;` sequence after an error,
|
||||
// and `failed` needs tmux >= 3.2 on the REMOTE host. Trailing, a rejection
|
||||
// costs only this option instead of every setting after it.
|
||||
expect(command.trimEnd().endsWith("remain-on-exit failed'")).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults claude to a non-interactive launch (--dangerously-skip-permissions)', () => {
|
||||
@@ -146,7 +164,13 @@ describe('TmuxManager (unit)', () => {
|
||||
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
|
||||
sessionId: 'abc123def456',
|
||||
});
|
||||
expect(command).toContain('exec claude --dangerously-skip-permissions');
|
||||
// Routed through an interactive login shell so ~/.local/bin (where `claude`
|
||||
// typically lives) is on PATH — ssh's remote-command execution is neither
|
||||
// interactive nor login, so a bare `exec claude` fails with "command not found".
|
||||
// The inner quoting is escaped twice over (once per shellescape() layer), so
|
||||
// assert on the unescaped substrings rather than the literal quoted form.
|
||||
expect(command).toContain('exec "${SHELL:-/bin/sh}" -i -l -c');
|
||||
expect(command).toContain('claude --dangerously-skip-permissions');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user