agent command; the legacy cursor-agent alias remains supported for older installations.
## Remote access
diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md
index aa8968c9..91d3ad96 100644
--- a/docs/readme/README.ja.md
+++ b/docs/readme/README.ja.md
@@ -78,18 +78,20 @@ Linux x86_64 (glibc 2.35+)、tmux、ユーザーレベルの systemd、`curl`/`t
以下のエージェントはすべて自動検出され、直接入力と ChatMux からの新規セッション開始に対応します。インデックス済みの履歴は会話として表示され、ライブ CLI 出力はいつでも確認できます。
-| エージェント | チャットビュー |
-|---|---|
-| **Claude Code** | 履歴のインデックス後 |
-| **Codex CLI** | 履歴のインデックス後 |
-| **Cursor CLI** | 履歴のインデックス後 |
-| **OpenCode** | 履歴のインデックス後 |
-| **Oh My OpenAgent** (`omo`) | 履歴のインデックス後 |
-| **Oh My Pi** | 履歴のインデックス後 |
-| **Gajae Code (GJC)** | ネイティブ対応 |
+| エージェント | チャットビュー | 起動時のフルアクセス |
+|---|---|---|
+| **Claude Code** | 履歴のインデックス後 | 対応 |
+| **Codex CLI** | 履歴のインデックス後 | 対応 |
+| **Cursor CLI** | 履歴のインデックス後 | 非対応 |
+| **OpenCode** | 履歴のインデックス後 | 対応 |
+| **Oh My OpenAgent** (`omo`) | 履歴のインデックス後 | 対応 |
+| **Oh My Pi** | 履歴のインデックス後 | 対応 |
+| **Gajae Code (GJC)** | ネイティブ対応 | 該当なし |
SSH tmux とローカルシェルも、ターミナル専用接続として利用できます。
+**フルアクセス**は、新しく起動するローカルセッションごとに明示的に選択するオプションです。設定は保存されず、既存またはリモートのセッションには影響しません。CLI に応じて、そのプロバイダーで検証済みの自動承認またはフルアクセスモードを選択するため、ツール、シェルコマンド、ファイル変更が追加確認なしで実行される場合があります。GJC は独自のデフォルト許可ポリシーを使用し、個別の起動スイッチはありません。Cursor には検証済みの対話型フルアクセスフラグがありません。
+
Cursor セッションはドキュメントに記載された agent コマンドを使用します。古いインストール向けにレガシーの cursor-agent エイリアスも引き続きサポートされます。
## リモートアクセス
diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md
index 671e266d..bc087248 100644
--- a/docs/readme/README.ko.md
+++ b/docs/readme/README.ko.md
@@ -80,18 +80,20 @@ Linux x86_64(glibc 2.35+)와 tmux, 사용자 레벨 systemd, `curl`/`tar`/`sha25
아래 에이전트는 모두 자동으로 발견되며 직접 입력과 ChatMux에서의 새 세션 시작을 지원합니다. 인덱싱된 히스토리는 대화로 표시되고, 실시간 CLI 출력은 언제든 볼 수 있습니다.
-| 에이전트 | 채팅 뷰 |
-|---|---|
-| **Claude Code** | 히스토리 인덱싱 후 |
-| **Codex CLI** | 히스토리 인덱싱 후 |
-| **Cursor CLI** | 히스토리 인덱싱 후 |
-| **OpenCode** | 히스토리 인덱싱 후 |
-| **Oh My OpenAgent** (`omo`) | 히스토리 인덱싱 후 |
-| **Oh My Pi** | 히스토리 인덱싱 후 |
-| **Gajae Code (GJC)** | 기본 지원 |
+| 에이전트 | 채팅 뷰 | 시작 시 모두 허용 |
+|---|---|---|
+| **Claude Code** | 히스토리 인덱싱 후 | 지원 |
+| **Codex CLI** | 히스토리 인덱싱 후 | 지원 |
+| **Cursor CLI** | 히스토리 인덱싱 후 | 미지원 |
+| **OpenCode** | 히스토리 인덱싱 후 | 지원 |
+| **Oh My OpenAgent** (`omo`) | 히스토리 인덱싱 후 | 지원 |
+| **Oh My Pi** | 히스토리 인덱싱 후 | 지원 |
+| **Gajae Code (GJC)** | 기본 지원 | 해당 없음 |
SSH tmux와 로컬 셸도 터미널 전용 연결로 지원합니다.
+**모두 허용**은 새로 시작하는 로컬 세션마다 명시적으로 선택하는 옵션입니다. 선택은 저장되지 않으며 기존 세션이나 원격 세션에는 영향을 주지 않습니다. CLI에 따라 해당 프로바이더에서 검증된 자동 승인 또는 전체 권한 모드를 사용하므로, 도구 실행과 셸 명령, 파일 변경이 추가 확인 없이 진행될 수 있습니다. GJC는 자체 기본 허용 정책을 사용하고 별도 시작 옵션이 없으며, Cursor는 검증된 대화형 전체 권한 옵션이 없습니다.
+
Cursor 세션은 문서화된 agent 명령을 사용합니다. 구형 설치를 위한 레거시 cursor-agent 별칭도 계속 지원됩니다.
## 원격 접속
diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md
index f1140346..92dc1413 100644
--- a/docs/readme/README.zh-CN.md
+++ b/docs/readme/README.zh-CN.md
@@ -78,18 +78,20 @@ curl -fsSL https://github.com/devswha/chatmux/releases/latest/download/install.s
以下代理均会被自动发现,支持直接输入,也可从 ChatMux 启动新会话。已索引的历史记录会显示为对话,实时 CLI 输出则始终可用。
-| 代理 | 聊天视图 |
-|---|---|
-| **Claude Code** | 历史索引后 |
-| **Codex CLI** | 历史索引后 |
-| **Cursor CLI** | 历史索引后 |
-| **OpenCode** | 历史索引后 |
-| **Oh My OpenAgent** (`omo`) | 历史索引后 |
-| **Oh My Pi** | 历史索引后 |
-| **Gajae Code (GJC)** | 原生支持 |
+| 代理 | 聊天视图 | 启动时完全访问 |
+|---|---|---|
+| **Claude Code** | 历史索引后 | 支持 |
+| **Codex CLI** | 历史索引后 | 支持 |
+| **Cursor CLI** | 历史索引后 | 不支持 |
+| **OpenCode** | 历史索引后 | 支持 |
+| **Oh My OpenAgent** (`omo`) | 历史索引后 | 支持 |
+| **Oh My Pi** | 历史索引后 | 支持 |
+| **Gajae Code (GJC)** | 原生支持 | 不适用 |
SSH tmux 和本地 shell 也可作为纯终端连接使用。
+**完全访问**是为每个新启动的本地会话明确选择的选项。该选项不会保存,也不会影响现有会话或远程会话。根据 CLI,它会选择该提供商已验证的自动批准或完全访问模式,因此工具、Shell 命令和文件更改可能无需再次确认即可执行。GJC 使用其原生的默认允许策略,没有单独的启动开关;Cursor 没有经过验证的交互式完全访问参数。
+
Cursor 会话使用文档中的 agent 命令;为旧安装保留了传统的 cursor-agent 别名。
## 远程访问
diff --git a/server/modules/providers/README.md b/server/modules/providers/README.md
index 5fe7ce81..65726648 100644
--- a/server/modules/providers/README.md
+++ b/server/modules/providers/README.md
@@ -45,6 +45,35 @@ Here, `omo` is the CLI/provider id for **Oh My OpenAgent**.
Those ids are mirrored in backend unions and frontend provider constants. If
adding a new provider, update every place that hardcodes this list.
+## External Session Startup Capabilities
+
+The new-session form can opt a newly launched local native CLI into that
+provider's verified interactive auto-approval or full-access mode. This is a
+per-request boolean: it is off by default, is not persisted, and is not sent to
+remote fleet spawns or applied to existing sessions.
+
+`shared/external-cli-spawn.ts` is the shared capability list. The client uses it
+to enable the checkbox, but `provider.routes.ts` remains authoritative: it
+validates the boolean, rejects unsupported providers, and passes only fixed
+server-owned arguments to the tmux launcher. Never accept arbitrary launch
+arguments from the browser.
+
+| Provider | Full-access startup | Native interactive arguments |
+| --- | --- | --- |
+| Claude | Supported | `--dangerously-skip-permissions` |
+| Codex | Supported | `--dangerously-bypass-approvals-and-sandbox` |
+| Cursor | Unsupported | No verified interactive full-access flag |
+| OpenCode | Supported | `--auto` |
+| GJC | Not applicable | Guarded tools use the native default-allow policy; there is no per-launch switch |
+| Oh My Pi (`omp`) | Supported | `--approval-mode=yolo` |
+| Oh My OpenAgent (`omo`) | Supported | `--approve --permission-preset full-access` |
+
+These provider modes are not semantically identical. Document them as
+auto-approval or full-access modes rather than claiming that every provider
+disables the same sandbox boundary. Keep the UI warning visible when the option
+is selected: tools, shell commands, and file changes may proceed without another
+approval in that session.
+
## Current File Layout
Each provider lives under its own folder in `server/modules/providers/list/`:
diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts
index 5c069c2f..f6a6cb93 100644
--- a/server/modules/providers/provider.routes.ts
+++ b/server/modules/providers/provider.routes.ts
@@ -79,6 +79,8 @@ import type {
import { resolveWorkspaceDirectoryWithinRoot } from '@/shared/workspace-paths.js';
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
+import { EXTERNAL_SPAWN_CLIS, supportsExternalCliFullAccess } from '../../../shared/external-cli-spawn.js';
+
import { attachCapabilityService } from './services/attach-capability.service.js';
@@ -979,14 +981,14 @@ router.post(
router.post(
'/sessions/external/spawn',
asyncHandler(async (req: Request, res: Response) => {
- const body = (req.body ?? {}) as { name?: unknown; cwd?: unknown; cli?: unknown };
+ const body = (req.body ?? {}) as { name?: unknown; cwd?: unknown; cli?: unknown; fullAccess?: unknown };
if (!isValidSpawnName(body.name)) {
throw new AppError('A valid session name is required (alphanumeric, not "company").', {
code: 'INVALID_SPAWN_NAME',
statusCode: 400,
});
}
- const supportedClis: ExternalSpawnCli[] = ['claude', 'codex', 'cursor', 'opencode', 'omp', 'omo'];
+ const supportedClis: readonly ExternalSpawnCli[] = EXTERNAL_SPAWN_CLIS;
if (body.cli !== undefined && !supportedClis.includes(body.cli as ExternalSpawnCli)) {
throw new AppError(`cli must be one of: ${supportedClis.join(', ')}.`, {
code: 'INVALID_CLI',
@@ -996,6 +998,19 @@ router.post(
const cli: ExternalSpawnCli = body.cli === undefined
? 'codex'
: body.cli as ExternalSpawnCli;
+ if (body.fullAccess !== undefined && typeof body.fullAccess !== 'boolean') {
+ throw new AppError('fullAccess must be a boolean.', {
+ code: 'INVALID_FULL_ACCESS',
+ statusCode: 400,
+ });
+ }
+ const fullAccess = body.fullAccess === true;
+ if (fullAccess && !supportsExternalCliFullAccess(cli)) {
+ throw new AppError(`${cli} does not support full-access startup.`, {
+ code: 'UNSUPPORTED_FULL_ACCESS',
+ statusCode: 400,
+ });
+ }
const cwdInput = typeof body.cwd === 'string' ? body.cwd.trim() : '';
if (!cwdInput) {
throw new AppError('cwd is required.', { code: 'EMPTY_CWD', statusCode: 400 });
@@ -1008,7 +1023,7 @@ router.post(
});
}
try {
- await spawnExternalCliSession(cli, body.name, cwd);
+ await spawnExternalCliSession(cli, body.name, cwd, { fullAccess });
} catch {
throw new AppError('The external CLI session could not be created; the tmux name may already exist.', {
code: 'EXTERNAL_CLI_SPAWN_FAILED',
diff --git a/server/modules/providers/services/external-cli-sessions.service.ts b/server/modules/providers/services/external-cli-sessions.service.ts
index d9b1a01c..850682cd 100644
--- a/server/modules/providers/services/external-cli-sessions.service.ts
+++ b/server/modules/providers/services/external-cli-sessions.service.ts
@@ -4,7 +4,7 @@ export { selectMostRecentCodexThreadId, selectObservedCodexThread, isCodexMainTh
export { parseClaudeRuntimeSession, isCodexRuntimeProcess, isClaudeRuntimeProcess, classifyExternalSessions, selectPrimaryCodexProcessPid } from './external-cli-sessions/process-classification.js';
export type { ExternalCliSessionCommandRunner } from './external-cli-sessions/process-classification.js';
export { extractContainedTranscriptSessionId, applyInferredProviderSessionIds, claudeReceiptPaneTag, isRunningProcess, selectClaudePaneReceipt, selectParkedClaudeReceipt } from './external-cli-sessions/provider-runtime-inference.js';
-export { normalizeExternalPaneOutput, resolveExternalCliCwd, withoutNodeModulesBins, buildExternalCliRuntimePath, resolveExternalCliExecutable, buildExternalCliTmuxSpawnArgs, spawnExternalCliSession } from './external-cli-sessions/inference-and-spawn.js';
+export { normalizeExternalPaneOutput, resolveExternalCliCwd, withoutNodeModulesBins, buildExternalCliRuntimePath, resolveExternalCliExecutable, buildExternalCliTmuxSpawnArgs, externalCliFullAccessArgs, spawnExternalCliSession } from './external-cli-sessions/inference-and-spawn.js';
export type { ExternalSpawnCli, CurrentTmuxPaneIdentity } from './external-cli-sessions/inference-and-spawn.js';
export { getCurrentTmuxPaneIdentityState, getCurrentTmuxPaneIdentity, resolveCodexRolloutPath, createExternalCliSessionDiscovery, getExternalCliSessionsDetailed } from './external-cli-sessions/discovery.js';
export type { ExternalCliSessionDiscovery, ExternalCliSessionDiscoveryOptions } from './external-cli-sessions/discovery.js';
diff --git a/server/modules/providers/services/external-cli-sessions/codex-fork-inference.ts b/server/modules/providers/services/external-cli-sessions/codex-fork-inference.ts
index 425fd8ac..adf965a7 100644
--- a/server/modules/providers/services/external-cli-sessions/codex-fork-inference.ts
+++ b/server/modules/providers/services/external-cli-sessions/codex-fork-inference.ts
@@ -18,6 +18,7 @@ const MAX_TAIL_BYTES = 1024 * 1024;
const MAX_LOG_ROWS = 256;
const ANCHOR_LENGTH = 96;
const MIN_ANCHOR_LENGTH = 64;
+export const CODEX_ANCHORLESS_BINDING_RECHECK_MS = 60_000;
/** Text is evidence for display only, never a native session-binding receipt. */
export function normalizeCodexDisplayText(text: string): string {
@@ -55,8 +56,7 @@ export function selectCodexForkByDisplay(args: {
}): string | null {
if (args.anchor.length < MIN_ANCHOR_LENGTH || args.anchor.length > ANCHOR_LENGTH) return null;
// /new or /resume can leave old cells in scrollback above a new welcome card.
- const headers = [...args.paneOutput.matchAll(/OpenAI Codex \(v[^\n]*\)/g)];
- const output = args.paneOutput.slice(headers.at(-1)?.index ?? 0);
+ const output = currentCodexPaneOutput(args.paneOutput);
if (!normalizeCodexDisplayText(output).includes(args.anchor)) return null;
const matches = new Set(args.candidates.filter((candidate) => (
candidate.messages.some((message) => normalizeCodexDisplayText(message).includes(args.anchor))
@@ -65,12 +65,71 @@ export function selectCodexForkByDisplay(args: {
}
type ForkTranscript = { id: string; parentId?: string; messages: string[] };
+type InitialDisplayCandidate = ForkTranscript & { cwd: string };
+
+function currentCodexPaneOutput(paneOutput: string): string {
+ const headers = [...paneOutput.matchAll(/OpenAI Codex \(v[^\n]*\)/g)];
+ return paneOutput.slice(headers.at(-1)?.index ?? 0);
+}
+
+function transcriptMessageAnchor(message: string): string | null {
+ const normalized = normalizeCodexDisplayText(message);
+ const anchor = normalized.slice(-ANCHOR_LENGTH);
+ return anchor.length >= MIN_ANCHOR_LENGTH && new Set(anchor).size >= 12 ? anchor : null;
+}
+
+/**
+ * A shared app-server owns the rollout file, so a new TUI may have no
+ * process-local file descriptor or `source = 'cli'` state row. The exact cwd
+ * narrows the app-server candidates; render and pane text must still select a
+ * single transcript before ChatMux exposes a display-only binding.
+ */
+export function selectInitialCodexThreadByDisplay(args: {
+ cwd?: string;
+ anchor?: string;
+ paneOutput: string;
+ candidates: readonly InitialDisplayCandidate[];
+}): string | null {
+ if (!args.cwd) return null;
+ const candidates = args.candidates.filter((candidate) => candidate.cwd === args.cwd);
+ if (args.anchor) {
+ return selectCodexForkByDisplay({
+ anchor: args.anchor,
+ paneOutput: args.paneOutput,
+ candidates,
+ });
+ }
+
+ // A newly attached TUI restores transcript cells without replaying their
+ // markdown-stream deltas. In that startup window, use only a long, diverse
+ // assistant-message suffix visible below the newest Codex welcome card. The
+ // cwd and uniqueness checks keep this a conservative display-only fallback.
+ const output = normalizeCodexDisplayText(currentCodexPaneOutput(args.paneOutput));
+ const matches = new Set(candidates.filter((candidate) => candidate.messages.some((message) => {
+ const messageAnchor = transcriptMessageAnchor(message);
+ return messageAnchor !== null && output.includes(messageAnchor);
+ })).map((candidate) => candidate.id));
+ return matches.size === 1 ? [...matches][0] : null;
+}
+
type CachedTranscript = { signature: string; transcript: ForkTranscript };
-type CachedForkBinding = { processKey: string; anchor: string; selectedId: string };
+type CachedForkBinding = { processKey: string; anchor?: string; selectedId: string; validatedAtMs: number };
// Bounded, server-private text cache. No prompt bodies enter logs or descriptors.
const transcriptCache = new Map