Merge origin/master into #644: keep diff|patch beside #621's tsv and zip

Master's #621 added tsv and zip to both file-link extension groups in
constants.js while this branch added diff|patch to the same lines. The
resolution keeps both, in master's order with diff|patch after sql where the
PR put it; the rooted and relative groups stay identical, and every upload
document extension plus .diff/.patch still links in both patterns.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 08:12:35 +02:00
commit 14a9e66547
159 files changed
+10409 -591

No files matched your search

+13 -2
View File
@@ -15,7 +15,9 @@
* `Session` import, no IO; the server injects emit callbacks (`onPending`/
* `onUpdated`/`onResolved`), which keeps this unit-testable and cycle-free.
* - Items are in-memory only. A server restart drops them; the next prompt
* re-fires the hook. Claude-mode sessions only (hooks fire for nothing else).
* re-fires the hook. Answerable items are Claude's alone; the DeepSeek status
* bridge and the screen-read dialogs of a hook-less CLI (codex, see
* `web/screen-blocked.ts`) open alert-only items (`answerInTerminal`).
* - Answer flow is take-then-write: `take()` removes the item BEFORE keystrokes
* are sent so a double-tap cannot double-send; `restore()` re-inserts on a
* failed write unless a newer prompt arrived meanwhile.
@@ -24,7 +26,8 @@
*
* @dependencies utils (stripAnsi, CLAUDE_WORKING_LINE_PATTERN)
* @consumedby web/routes/hook-event-routes (notePrompt/resolve), web/routes/approval-routes,
* web/session-listener-wiring (working/exit resolution), web/server (emit callbacks + stop)
* web/session-listener-wiring (working/exit resolution), web/screen-blocked (screen-read
* dialogs), web/server (emit callbacks + stop)
*
* @module web/approval-inbox
*/
@@ -122,6 +125,13 @@ interface NotePromptArgs {
answerInTerminal?: boolean;
/** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */
capture?: () => string | null;
/**
* A frame the caller already read, kept as the card's context when there is no
* `capture`. Never parsed for options and never re-read: an item raised from the
* screen (`web/screen-blocked.ts`) belongs to the session that keeps reading it, and
* the option parser and staleness sweep here are shaped for Claude's dialogs.
*/
context?: string;
}
// ─── Tunables ────────────────────────────────────────────────────────────────
@@ -279,6 +289,7 @@ export class ApprovalInbox {
item.acknowledgedAt = item.createdAt;
item.acknowledgedReason = `watching ${args.watching}`;
}
if (!args.capture && args.context) item.context = normalizeCapturedFrame(args.context);
this.applyCapture(item, args.capture);
this.items.set(args.sessionId, item);
if (args.capture) this.captures.set(args.sessionId, args.capture);
+99 -33
View File
@@ -533,8 +533,8 @@ const DEFAULT_SHORTCUTS = [
group: 'Session',
label: 'Toggle Session Sidebar',
// Alt+B, not Ctrl+B: Ctrl+B must reach the terminal (tmux prefix,
// readline backward-char). The Alt block below claims only Digit1-9 and
// the brackets, and the registry claims Alt for KeyK and Slash only.
// readline backward-char). The Alt block below claims only Digit1-9; the
// brackets are registry entries, and the registry claims Alt for KeyK and Slash only.
bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }],
action: 'toggleSessionSidebar',
},
@@ -638,11 +638,22 @@ const DEFAULT_SHORTCUTS = [
bindings: [],
action: 'removeTile',
},
// Physical key codes, so macOS Option layouts that print "“" for Option+[ still match.
{
id: 'previous-next-session',
id: 'previous-session',
group: 'Session',
label: 'Previous / Next Session',
displayBindings: ['Alt/Option+[', 'Alt/Option+]'],
label: 'Previous Session',
displayBindings: ['Alt/Option+['],
bindings: [{ modifiers: ['alt'], code: 'BracketLeft' }],
action: 'previousSession',
},
{
id: 'next-session-alt',
group: 'Session',
label: 'Next Session (alternate)',
displayBindings: ['Alt/Option+]'],
bindings: [{ modifiers: ['alt'], code: 'BracketRight' }],
action: 'nextSession',
},
{
id: 'switch-tab-n',
@@ -1388,19 +1399,7 @@ class CodemanApp {
// getShortcutRegistry()). The command palette chord is deliberately NOT in
// this map — shouldOpenCommandPaletteFromShortcut() dispatches it above with
// focus-target awareness (it must fire from the terminal but not from inputs).
const SHORTCUT_ACTIONS = {
showShortcutOverlay: () => this.showShortcutOverlay(),
killActiveSession: () => this.killActiveSession(),
nextSession: () => this.nextSession(),
clearTerminal: () => this.clearTerminal(),
restoreTerminalSize: () => this.restoreTerminalSize(),
increaseFontSize: () => this.increaseFontSize(),
decreaseFontSize: () => this.decreaseFontSize(),
toggleVoiceInput: () => VoiceInput.toggle(),
moveActiveTabLeft: () => this.moveActiveTabLeft(),
moveActiveTabRight: () => this.moveActiveTabRight(),
toggleSessionSidebar: () => this.toggleSessionSidebar(),
};
const SHORTCUT_ACTIONS = this._shortcutActions();
// Use capture to handle before terminal
document.addEventListener('keydown', (e) => {
@@ -1447,6 +1446,7 @@ class CodemanApp {
this.closeAllPanels();
this.closeHelp();
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
this.closeWorktreeModal?.();
this.closeSessionManager();
this.closeCommandPalette?.();
this.closeShortcutOverlay?.();
@@ -1463,7 +1463,8 @@ class CodemanApp {
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
// keyboard layouts that emit special characters under Option (Option+1 -> ¡,
// Option+[ -> "“") still switch sessions. e.code is the physical key regardless
// of layout. Option+1-9 = switch by index; Option+[ / Option+] = prev / next.
// of layout. Option+1-9 = switch by index. Option+[ / ] (prev / next) live in the
// shortcut registry, so they can be rebound.
if (e.altKey && !e.ctrlKey && !e.shiftKey) {
const code = e.code || '';
const digitMatch = code.match(/^Digit([1-9])$/);
@@ -1489,16 +1490,6 @@ class CodemanApp {
}
return;
}
if (e.code === 'BracketLeft') {
e.preventDefault();
this.prevSession();
return;
}
if (e.code === 'BracketRight') {
e.preventDefault();
this.nextSession();
return;
}
}
// Tile grid chords: only where they apply (tile-grid.js tileShortcutFor),
@@ -4843,6 +4834,8 @@ class CodemanApp {
}
}
if (data.version) this.checkWhatsNew?.(data.version);
// Stop any active voice recording on reconnect
VoiceInput.cleanup();
@@ -10336,9 +10329,10 @@ class CodemanApp {
delete this.respawnActionLogs[sessionId];
}
/** Resolves true once the session is gone from the server, false when it is still there. */
async closeSession(sessionId, killMux = true) {
// Already on its way out (a repeated click, the mux panel racing the tab).
if (this._closingSessions.has(sessionId)) return;
if (this._closingSessions.has(sessionId)) return false;
// The tab goes FIRST and the server is asked after. The kill takes the server
// a few hundred ms (SIGTERM grace, the process tree, tmux), and a tab that sat
// there that long after "Kill" read as a click that did nothing. A refused
@@ -10416,7 +10410,7 @@ class CodemanApp {
} else {
this.showToast('Tab hidden, tmux still running', 'info');
}
return;
return true;
}
// Still on the server (or the server is unreachable, in which case the resync
@@ -10431,6 +10425,7 @@ class CodemanApp {
this.renderSessionTabs();
}
this.showToast('Failed to close session', 'error');
return false;
}
// Request confirmation before closing a session
@@ -10445,6 +10440,20 @@ class CodemanApp {
const sessionNameEl = document.getElementById('closeConfirmSessionName');
sessionNameEl.textContent = name;
// A session in a Codeman-managed git worktree also offers to remove the worktree
// (git worktree remove, never forced) once the session is gone. Read now, before the
// session leaves this.sessions.
const worktree = this._managedWorktreeOfSession?.(session) || null;
this.pendingCloseWorktree = worktree;
const wtOption = document.getElementById('closeConfirmWorktree');
if (wtOption) {
wtOption.hidden = !worktree;
const wtName = document.getElementById('closeConfirmWorktreeName');
if (wtName) wtName.textContent = worktree ? worktree.name : '';
const wtBranch = document.getElementById('closeConfirmWorktreeBranch');
if (wtBranch) wtBranch.checked = false;
}
// Update kill button text based on session mode
const killTitle = document.getElementById('closeConfirmKillTitle');
if (killTitle) {
@@ -10472,15 +10481,24 @@ class CodemanApp {
cancelCloseSession() {
this.pendingCloseSessionId = null;
this.pendingCloseWorktree = null;
document.getElementById('closeConfirmModal').classList.remove('active');
}
async confirmCloseSession(killMux = true) {
/**
* `removeWorktree`: Close Session's "Kill & remove worktree". The worktree is removed only
* after the server confirms the session is gone (a live session inside is refused anyway),
* and only ever through `git worktree remove` without --force.
*/
async confirmCloseSession(killMux = true, { removeWorktree = false } = {}) {
const sessionId = this.pendingCloseSessionId;
const worktree = removeWorktree ? this.pendingCloseWorktree : null;
const deleteBranch = !!document.getElementById('closeConfirmWorktreeBranch')?.checked;
this.cancelCloseSession();
if (sessionId) {
await this.closeSession(sessionId, killMux);
const gone = await this.closeSession(sessionId, killMux);
if (gone && worktree) await this._removeWorktreeAfterClose?.(worktree, deleteBranch);
}
}
@@ -10698,6 +10716,54 @@ class CodemanApp {
}
// ─── Shortcut Registry ───────────────────────────────────────────────────────
/**
* Action name -> handler for the shortcut registry entries the document keydown
* handler dispatches generically (DEFAULT_SHORTCUTS + the user's rebinds). Entries
* absent here (copy selection, the palette, the tile chords) have their own dispatch.
*/
_shortcutActions() {
return {
showShortcutOverlay: () => this.showShortcutOverlay(),
killActiveSession: () => this.killActiveSession(),
nextSession: () => this.nextSession(),
previousSession: () => this.prevSession(),
clearTerminal: () => this.clearTerminal(),
restoreTerminalSize: () => this.restoreTerminalSize(),
increaseFontSize: () => this.increaseFontSize(),
decreaseFontSize: () => this.decreaseFontSize(),
toggleVoiceInput: () => VoiceInput.toggle(),
moveActiveTabLeft: () => this.moveActiveTabLeft(),
moveActiveTabRight: () => this.moveActiveTabRight(),
toggleSessionSidebar: () => this.toggleSessionSidebar(),
};
}
/**
* True when `e` is a chord the USER rebound to a generically dispatched action.
* The document handler has already run the action, but its preventDefault() does not
* stop xterm, so without this gate a rebind to e.g. Alt+J would also type ESC j into the
* session. Defaults are not covered on purpose: several (Ctrl+L) rely on reaching the PTY,
* and neither is a chord that matches the entry's own default (a re-capture of it).
*/
isUserBoundShortcutEvent(e) {
if (!e.ctrlKey && !e.metaKey && !e.altKey) return false;
const overrides = this.loadAppSettingsFromStorage().shortcutOverrides || {};
const actions = this._shortcutActions();
return this.getShortcutRegistry().some((shortcut) => {
if (shortcut.disabled || !Array.isArray(overrides[shortcut.id]?.bindings)) return false;
// toggleSessionSidebar has its own gate (shouldToggleSessionSidebarFromShortcut) that
// lets the chord through when the sidebar layout is off; swallowing it here would make a
// rebound sidebar key dead in the header layout.
if (shortcut.action === 'toggleSessionSidebar') return false;
if (typeof actions[shortcut.action] !== 'function') return false;
if (!this.matchesShortcutEvent(e, shortcut)) return false;
// Re-capturing the entry's own default (Ctrl+L for Clear Terminal) must keep reaching the
// CLI: clearTerminal() relies on ^L for the redraw.
const fallback = DEFAULT_SHORTCUTS.find((d) => d.id === shortcut.id);
return !this.matchesShortcutEvent(e, fallback);
});
}
// Returns the merged shortcut list: DEFAULT_SHORTCUTS with any per-shortcut
// overrides from settings.shortcutOverrides applied on top.
+2 -2
View File
@@ -2047,7 +2047,7 @@ function computeRewriteScrollLine(input) {
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
*/
const FILE_PATH_LINK_PATTERN =
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
function absoluteFilePathPattern() {
@@ -2100,7 +2100,7 @@ function previewsInFileViewer(filePath) {
* {@link findFilePathLinks}.
*/
const RELATIVE_FILE_PATH_LINK_PATTERN =
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */
const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1];
+73 -3
View File
@@ -168,8 +168,8 @@
'Restore the grid': '恢复平铺网格',
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
// A file of an unsupported type dropped on a tile (tile-grid.js); the single
// view's drop (image-input.js) uses 'Only image or video files are supported'.
// A file of an unsupported type dropped on a tile (tile-grid.js) or the single
// view (image-input.js).
'Unsupported file type': '不支持的文件类型',
// Redraw (Ctrl+Shift+R, terminal-ui.js restoreTerminalSize) on the main pane, a tile or Pane B.
'No active session': '没有活动会话',
@@ -265,6 +265,50 @@
'New or link a case…': '新建或关联案例…',
'Case settings…': '案例设置…',
'No cases match': '没有匹配的案例',
// Git worktrees of a case (session-ui.js openWorktreeModal, the case picker row, the
// phone case sheet and Close Session's worktree option). Worktree and branch names,
// paths and git's own messages are user/git text and pass through untranslated.
'Git worktrees…': 'Git 工作树…',
'Git worktrees': 'Git 工作树',
'Close git worktrees': '关闭 Git 工作树',
Worktrees: '工作树',
'Worktrees…': '工作树…',
"Each worktree is its own checkout of the case's repository, on its own branch, in .claude/worktrees/ of the case. Agents working in parallel then never share one folder.":
'每个工作树都是案例仓库的独立检出,有自己的分支,位于案例的 .claude/worktrees/ 中。并行工作的智能体因此不会共用同一个文件夹。',
'Start sessions with': '启动会话所用',
'New worktree': '新建工作树',
Branch: '分支',
'Start from': '起点',
"New branch worktree-<name> from the case's current commit, unless you pick another below.":
'从案例当前提交新建分支 worktree-<name>,除非在下方另行选择。',
'Branch and start point': '分支和起点',
"A branch, tag or commit. Default: the case's current commit (HEAD).":
'分支、标签或提交。默认:案例当前提交(HEAD)。',
'Create & start session': '创建并启动会话',
'Loading worktrees…': '正在加载工作树…',
'No worktrees yet. Create one below.': '还没有工作树。请在下方创建。',
'detached HEAD': '游离 HEAD',
'main checkout': '主检出',
'not created by Codeman': '非 {name} 创建',
locked: '已锁定',
missing: '已丢失',
Open: '打开',
'Start a session in this worktree': '在此工作树中启动会话',
'Remove…': '移除…',
'git worktree remove refuses while the worktree has uncommitted or untracked files. Codeman never forces it.':
'工作树中有未提交或未跟踪的文件时,git worktree remove 会拒绝执行。{name} 从不强制移除。',
'Its folder is gone. Removing it clears the record git keeps of it, so the name can be used again.':
'它的文件夹已不存在。移除它会清除 git 保存的记录,这个名称便可再次使用。',
'Remove worktree': '移除工作树',
'Kill & remove worktree': '终止并移除工作树',
'git refuses while the worktree has uncommitted or untracked files; nothing is forced':
'工作树中有未提交或未跟踪的文件时 git 会拒绝;不会强制执行',
'Also delete its branch (git branch -d keeps a branch that is not merged)':
'同时删除其分支(git branch -d 会保留未合并的分支)',
'Name: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 64 characters.':
'名称:字母、数字、"."、"_" 和 "-",以字母或数字开头,最多 64 个字符。',
'A launch is already in progress': '已有启动正在进行',
'Codeman did not answer.': '{name} 没有响应。',
'Add Case': '添加案例',
'Open sessions': '打开会话',
'Recent Sessions': '最近会话',
@@ -691,7 +735,7 @@
// the DOM translator skips <textarea> subtrees.
'Compose prompt': '撰写提示词',
'Open a session to attach a file': '请先打开一个会话再添加附件',
'Only image or video files are supported': '仅支持图片或视频文件',
'📎 Attach': '📎 添加附件',
'Compose prompt, draft saved': '撰写提示词,草稿已保存',
'Resume saved prompt draft': '继续编辑已保存的提示词草稿',
'Enter adds a new line': '按 Enter 换行',
@@ -798,9 +842,16 @@
// Dynamic common status / toasts
'Settings saved': '设置已保存',
// The once-per-release toast (settings-ui.js checkWhatsNew); its highlights stay English.
"See what's new": '查看更新内容',
'Settings applied': '设置已应用',
'Save and keep Settings open': '保存并保持设置打开',
'Settings saved locally': '设置已保存到本机',
// Multi-user mode, a non-admin (settings-ui.js / session-ui.js): server-wide settings
// writes are admin-only there.
'Saved on this device. Server-wide settings can only be changed by an admin.':
'已保存到本设备。服务器全局设置只能由管理员更改。',
'Server-wide settings can only be changed by an admin': '服务器全局设置只能由管理员更改',
'Tunnel active': '隧道已启用',
'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…',
'Push notifications enabled': '推送通知已启用',
@@ -1179,6 +1230,8 @@
'Close Panels': '关闭面板',
'Previous / Next Session': '上一个 / 下一个会话',
'Next Session': '下一个会话',
'Previous Session': '上一个会话',
'Next Session (alternate)': '下一个会话(备用)',
'Switch to Tab N': '切换到第 N 个标签页',
'Move Active Tab Left': '向左移动当前标签页',
'Move Active Tab Right': '向右移动当前标签页',
@@ -1244,6 +1297,10 @@
[/^Update to v(.+)$/, (_m, version) => `更新到 v${version}`],
[/^You're up to date \(v(.+)\)\.$/, (_m, version) => `已是最新版本(v${version})。`],
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
[
/^Codeman updated to v([^:\s]+): (.+?)(?: \(\+(\d+) more\))?$/,
(_m, version, shown, more) => `Codeman 已更新到 v${version}:${shown}${more ? `(另有 ${more} 项)` : ''}`,
],
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
@@ -1252,6 +1309,16 @@
[/^Terminal restored to (\d+)x(\d+)$/, (_m, cols, rows) => `终端已恢复为 ${cols}x${rows}`],
[/^View truncated to the first (\d+) cells$/, (_m, n) => `视图仅显示前 ${n} 个单元格`],
[/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`],
// Git worktree toasts (session-ui.js): worktree and branch names and git's own
// words pass through untranslated.
[/^Worktree (\S+) created on branch (\S+)$/, (_m, wt, branch) => `已创建工作树 ${wt},分支 ${branch}`],
[
/^Worktree (\S+) removed\. Branch (\S+) kept: ([\s\S]+)$/,
(_m, wt, branch, why) => `已移除工作树 ${wt}。保留了分支 ${branch}:${why}`,
],
[/^Worktree (\S+) and branch (\S+) removed$/, (_m, wt, branch) => `已移除工作树 ${wt} 和分支 ${branch}`],
[/^Worktree (\S+) removed$/, (_m, wt) => `已移除工作树 ${wt}`],
[/^Worktree (\S+) was kept: ([\s\S]+)$/, (_m, wt, why) => `工作树 ${wt} 已保留:${why}`],
// Group names are user text: they pass through untranslated.
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
// Session names are user text too.
@@ -1287,6 +1354,9 @@
(_m, n) => `窗口太小,容纳不下 ${n} 个窗格:只显示聚焦的窗格`,
],
[/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`],
// A dialog read off a hook-less CLI's screen (src/web/screen-blocked.ts), as the
// notification drawer shows it. The CLI's label passes through.
[/^(.+) is waiting for your answer in the terminal$/, (_m, cli) => `${cli} 正在终端中等待你的回答`],
[/^The agent exited \(signal (\d+)\)$/, (_m, signal) => `智能体已退出(信号 ${signal})`],
// A session header's harness logo (tile grid, split pane): "<harness> · <model>",
// and where the model came from when the CLI did not report it. The harness
+19 -10
View File
@@ -103,7 +103,7 @@ Object.assign(CodemanApp.prototype, {
const mediaFiles = Array.from(e.dataTransfer.files).filter((f) => this._promptAttachKind(f));
if (mediaFiles.length === 0) {
this.showToast('Only image or video files are supported', 'error');
this.showToast('Unsupported file type', 'error');
return;
}
this._uploadAndInsertImages(mediaFiles);
@@ -124,7 +124,7 @@ Object.assign(CodemanApp.prototype, {
async _attachFromToolbar(files, sessionId) {
const media = files.filter((f) => this._promptAttachKind(f));
if (media.length === 0) {
this.showToast('Only image or video files are supported', 'error');
this.showToast('Unsupported file type', 'error');
return;
}
if (!sessionId) return; // no captured session: refuse rather than guess at one
@@ -230,7 +230,7 @@ Object.assign(CodemanApp.prototype, {
if (pasteConsumed) return;
pasteConsumed = true;
// Check for images or videos in clipboard items
// Check for images, videos or documents in clipboard items
var imageFiles = [];
var items = e.clipboardData && e.clipboardData.items;
if (items) {
@@ -266,18 +266,27 @@ Object.assign(CodemanApp.prototype, {
document.execCommand('paste');
},
/** What a pick or a paste may upload: 'image', or 'video' for the camera
* roll's clips (the server streams those to disk under their own cap), else
* null. The one filter behind Attach, drop, the Compose and paste-dialog
* pickers and the clipboard handlers. The MIME type decides; some Android
* file managers hand over a File with none, so the name's extension is the
* fallback, mirroring the server's own allowlists. */
/** What a pick or a paste may upload: 'image', 'video' for the camera roll's
* clips (the server streams those to disk under their own cap), 'document'
* for the office and text files the server admits, else null. The one filter
* behind Attach, drop, the Compose and paste-dialog pickers and the clipboard
* handlers. For media the MIME type decides, but a video MIME only for the
* containers the server admits; some Android file managers hand over a File
* with none, so the name's extension is the fallback. A document is known by
* its extension alone, as on the server (the MIME a file manager gives a .md
* or .csv is anyone's guess). Mirrors the server's allowlists. */
_promptAttachKind(file) {
if (file.type.startsWith('image/')) return 'image';
if (file.type.startsWith('video/')) return 'video';
// A video MIME counts only for the five containers the server accepts on
// ?kind=video (its own set in session-routes.ts); a 3gp or mkv falls to the
// extension check below and is refused up front, not after a 1GB upload and a 400.
if (/^video\/(mp4|quicktime|x-m4v|webm|ogg)$/i.test(file.type)) return 'video';
const ext = (file.name || '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] || '';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'heic', 'heif'].includes(ext)) return 'image';
if (['mp4', 'webm', 'mov', 'm4v', 'ogv'].includes(ext)) return 'video';
if (['pdf', 'docx', 'pptx', 'xlsx', 'zip', 'txt', 'md', 'csv', 'tsv', 'json', 'log', 'yaml', 'yml', 'xml'].includes(ext)) {
return 'document';
}
return null;
},
+64 -1
View File
@@ -790,7 +790,7 @@
<path d="m21 15-5-5L5 21"/>
</svg>
</button>
<input type="file" class="btn-attach-mobile-input" id="attachFileInput" accept="image/*,video/*" multiple tabindex="-1" aria-hidden="true">
<input type="file" class="btn-attach-mobile-input" id="attachFileInput" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple tabindex="-1" aria-hidden="true">
</span>
<button class="btn-voice-mobile" id="voiceInputBtnMobile" onclick="VoiceInput.toggle()"
title="Voice input" aria-label="Start voice input" aria-pressed="false">
@@ -1693,6 +1693,18 @@
<span class="close-option-title" id="closeConfirmKillTitle">Kill Tmux & Claude Code</span>
<span class="close-option-desc">Terminate the session completely</span>
</button>
<!-- Shown only for a session in a Codeman-managed git worktree (requestCloseSession,
app.js): kill it, then `git worktree remove` without --force. -->
<div class="close-option-worktree" id="closeConfirmWorktree" hidden>
<button class="close-option close-option-danger" onclick="app.confirmCloseSession(true, { removeWorktree: true })">
<span class="close-option-title">Kill &amp; remove worktree <span id="closeConfirmWorktreeName" data-i18n-skip></span></span>
<span class="close-option-desc">git refuses while the worktree has uncommitted or untracked files; nothing is forced</span>
</button>
<label class="close-option-worktree-branch">
<input type="checkbox" id="closeConfirmWorktreeBranch">
<span>Also delete its branch (git branch -d keeps a branch that is not merged)</span>
</label>
</div>
</div>
<div class="modal-footer-cancel">
<button class="btn-toolbar btn-sm" onclick="app.cancelCloseSession()">Cancel</button>
@@ -1700,6 +1712,52 @@
</div>
</div>
<!-- Git worktrees of a case (session-ui.js openWorktreeModal; routes/worktree-routes.ts).
Opened from the case picker's "Git worktrees…" row and the phone case sheet. -->
<div class="modal" id="worktreeModal" role="dialog" aria-modal="true" aria-labelledby="worktreeModalTitle">
<div class="modal-backdrop" onclick="app.closeWorktreeModal()"></div>
<div class="modal-content worktree-modal">
<div class="modal-header">
<h3 id="worktreeModalTitle"><span>Git worktrees</span> <span class="worktree-modal-case" id="worktreeModalCase" data-i18n-skip></span></h3>
<button class="modal-close" onclick="app.closeWorktreeModal()" aria-label="Close git worktrees">&times;</button>
</div>
<div class="modal-body">
<p class="form-hint worktree-intro">Each worktree is its own checkout of the case's repository, on its own branch, in .claude/worktrees/ of the case. Agents working in parallel then never share one folder.</p>
<div class="worktree-status" id="worktreeStatus" role="status" aria-live="polite" hidden></div>
<div class="worktree-list" id="worktreeList"></div>
<div class="form-row worktree-mode-row">
<label for="worktreeMode">Start sessions with</label>
<select id="worktreeMode" class="form-select"></select>
</div>
<fieldset class="worktree-new">
<legend>New worktree</legend>
<div class="form-row">
<label for="worktreeName">Name</label>
<input type="text" id="worktreeName" maxlength="64" placeholder="fix-login" autocomplete="off" autocapitalize="off" spellcheck="false" onkeydown="if (event.key === 'Enter') app.createWorktreeFromModal()">
<span class="form-hint">New branch worktree-&lt;name&gt; from the case's current commit, unless you pick another below.</span>
</div>
<details class="worktree-advanced">
<summary>Branch and start point</summary>
<div class="form-row">
<label for="worktreeBranch">Branch</label>
<input type="text" id="worktreeBranch" maxlength="200" placeholder="worktree-<name>" autocomplete="off" autocapitalize="off" spellcheck="false">
</div>
<div class="form-row">
<label for="worktreeBase">Start from</label>
<input type="text" id="worktreeBase" maxlength="200" placeholder="HEAD" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">A branch, tag or commit. Default: the case's current commit (HEAD).</span>
</div>
</details>
<div class="worktree-form-error" id="worktreeFormError" role="alert" hidden></div>
</fieldset>
</div>
<div class="form-actions">
<button type="button" class="wt-btn" onclick="app.closeWorktreeModal()">Close</button>
<button type="button" class="wt-btn wt-btn-primary" id="worktreeCreateBtn" onclick="app.createWorktreeFromModal()">Create &amp; start session</button>
</div>
</div>
</div>
<!-- App Settings Modal -->
<div class="modal" id="appSettingsModal">
<div class="modal-backdrop" onclick="app.closeAppSettings()"></div>
@@ -3663,6 +3721,11 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create New Case
</button>
<!-- The selected case's git worktrees (session-ui.js openWorktreeModal). -->
<button class="btn-toolbar btn-case-create-mobile btn-case-worktrees-mobile" onclick="app.showWorktreesFromMobile()">
<span aria-hidden="true">&#x2442;</span>
Worktrees
</button>
</div>
</div>
</div>
+14 -14
View File
@@ -1282,11 +1282,11 @@ const KeyboardAccessoryBar = {
<textarea class="paste-textarea prompt-composer-textarea" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
<div class="paste-actions prompt-composer-actions">
<button type="button" class="prompt-composer-terminal">Use terminal keyboard</button>
<button type="button" class="paste-image">🖼 Image</button>
<button type="button" class="paste-image">📎 Attach</button>
<button type="button" class="paste-cancel">Cancel</button>
<button type="button" class="paste-send">Send</button>
</div>
<input type="file" class="paste-file-input" accept="image/*,video/*" multiple hidden>
<input type="file" class="paste-file-input" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple hidden>
</div>
`;
@@ -1354,7 +1354,7 @@ const KeyboardAccessoryBar = {
const currentSendButton = currentOverlay?.querySelector('.paste-send');
if (currentImageButton) {
currentImageButton.disabled = count > 0;
currentImageButton.textContent = count > 0 ? 'Uploading…' : '🖼 Image';
currentImageButton.textContent = count > 0 ? 'Uploading…' : '📎 Attach';
}
if (currentSendButton) currentSendButton.disabled = count > 0;
const currentTerminalButton = currentOverlay?.querySelector('.prompt-composer-terminal');
@@ -1469,13 +1469,13 @@ const KeyboardAccessoryBar = {
/** Show a paste overlay for iOS compatibility.
* Handles three input paths from one dialog:
* - Text: long-press the textarea → Paste → Send (unchanged).
* - Image (picker): the "Image" button opens a native file picker
* (accept=image/*,video/* → camera / photo library / files), the most reliable
* way to attach a photo on mobile.
* - Image (paste): if the browser exposes image blobs on the textarea's
* - File (picker): the "Attach" button opens a native file picker
* (accept=image/*,video/* plus the document extensions → camera / photo
* library / files), the most reliable way to attach anything on mobile.
* - File (paste): if the browser exposes file blobs on the textarea's
* paste event, we intercept them and upload directly. Support is spotty
* on mobile, so it is a best-effort enhancement layered on the picker.
* All image paths reuse app._uploadAndInsertImages() (image-input.js), which
* All upload paths reuse app._uploadAndInsertImages() (image-input.js), which
* uploads to /api/sessions/:id/paste-image and inserts the saved path. */
pasteFromClipboard() {
if (typeof app === 'undefined' || !app.activeSessionId) return;
@@ -1488,13 +1488,13 @@ const KeyboardAccessoryBar = {
overlay.className = 'paste-overlay';
overlay.innerHTML = `
<div class="paste-dialog">
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach a photo or video"></textarea>
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 📎 to attach a file"></textarea>
<div class="paste-actions">
<button class="paste-image">🖼 Image</button>
<button class="paste-image">📎 Attach</button>
<button class="paste-cancel">Cancel</button>
<button class="paste-send">Send</button>
</div>
<input type="file" class="paste-file-input" accept="image/*,video/*" multiple hidden>
<input type="file" class="paste-file-input" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple hidden>
</div>
`;
@@ -1519,8 +1519,8 @@ const KeyboardAccessoryBar = {
if (this._sendBracketedPaste(sessionId, text, 'Paste')) close();
};
// Filter to images and videos, close the dialog, and hand off to the shared
// upload+insert pipeline. Returns true if any image was handled.
// Filter to what the server admits, close the dialog, and hand off to the
// shared upload+insert pipeline. Returns true if any file was handled.
const handleImages = (files) => {
const images = Array.from(files || []).filter((f) => app._promptAttachKind(f));
if (images.length === 0) return false;
@@ -1530,7 +1530,7 @@ const KeyboardAccessoryBar = {
return true;
};
// Image picker (camera / photo library) — the reliable mobile path.
// File picker (camera / photo library / files) — the reliable mobile path.
overlay.querySelector('.paste-image').addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => handleImages(fileInput.files));
+12 -2
View File
@@ -597,8 +597,18 @@ Object.assign(CodemanApp.prototype, {
// Update local cache + legacy localStorage
this._serverRespawnPresets = custom;
localStorage.setItem('codeman-respawn-presets', JSON.stringify(custom));
// Persist to server (cross-device sync)
this._apiPut('/api/settings', { respawnPresets: custom }).catch(() => {});
// Persist to server (cross-device sync). Not for a non-admin in multi-user mode: the
// presets are a key of the server-wide settings.json there, and its PUT is admin-only
// (403), so they are kept as their own on this device and win over the server's on the
// next load (_rememberOwnSetting, settings-ui.js). A PUT refused that way (sent before the
// role was known) marks the page read-only for the writes after it.
if (this._settingsReadOnly?.()) {
this._rememberOwnSetting?.('respawnPresets', custom);
return;
}
this._apiPut('/api/settings', { respawnPresets: custom })
.then((res) => this._noteSettingsWriteRefusal?.(res))
.catch(() => {});
},
renderPresetDropdown() {
+525 -56
View File
@@ -92,7 +92,7 @@ const RUN_MODE_LAUNCH = {
},
deepseek: {
label: 'DeepSeek',
installHint: 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh',
installHint: 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)',
// The two-part availability check is deliberate. `dsh` being installed is
// not enough — DeepSeek ships no terminal front door, so a box can have a
// perfect binary and nothing a pane can run.
@@ -128,14 +128,37 @@ const CASE_PICKER_REFRESH_MS = 5000;
/**
* Action rows at the bottom of the toolbar case picker. They replaced the "+"
* and gear buttons that sat beside the picker (owner, 1.36.0 beta): the same two
* actions, one click away, without two extra controls in the toolbar. The arrow
* keys reach them after the last case; Enter or a click runs `run`.
* actions, one click away, without two extra controls in the toolbar, and the git
* worktrees of the selected case joined them. The arrow keys reach them after the
* last case; Enter or a click runs `run`.
*/
const CASE_PICKER_ACTIONS = [
{ id: 'add', icon: '+', label: 'New or link a case…', run: (app) => app.showCreateCaseModal() },
{ id: 'settings', icon: '\u2699', label: 'Case settings…', run: (app) => app.toggleCaseSettings() },
// The selected case's git worktrees (openWorktreeModal below): a row here rather than a
// toolbar button, for the same reason the two above replaced theirs.
{ id: 'worktrees', icon: '⑂', label: 'Git worktrees…', run: (app) => app.openWorktreeModal() },
];
/** A worktree name the server accepts (src/git-worktrees.ts `worktreeNameError`), checked before the POST. */
const WORKTREE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
/**
* The Codeman-managed worktree a working directory IS: `{ caseName, name, path }` when it is
* exactly `<case path>/.claude/worktrees/<name>` of a local case in `cases`, else null. Lexical
* on purpose (the browser cannot resolve links): the server re-checks every removal against the
* case's own repository, so a stale or wrong match can only be refused, never act elsewhere.
*/
function managedWorktreeOfDir(workingDir, cases) {
if (typeof workingDir !== 'string') return null;
const m = /^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/.exec(workingDir);
if (!m || !WORKTREE_NAME_RE.test(m[2]) || m[2].includes('..')) return null;
const owner = (cases || []).find(
(c) => c && c.path === m[1] && c.location !== 'remote' && c.location !== 'docker'
);
return owner ? { caseName: owner.name, name: m[2], path: workingDir.replace(/\/$/, '') } : null;
}
const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
const BUILT_IN_RUN_MODES = new Set(['claude', 'shell', ...Object.keys(RUN_MODE_LAUNCH)]);
@@ -604,6 +627,9 @@ Object.assign(CodemanApp.prototype, {
},
async saveLastUsedCase(caseName) {
// Multi-user non-admin: lastUsedCase is a key of the server-wide settings.json, whose
// PUT is admin-only (403), so it is not sent (_settingsReadOnly, settings-ui.js).
if (this._settingsReadOnly?.()) return;
try {
await fetch('/api/settings', {
method: 'PUT',
@@ -692,8 +718,18 @@ Object.assign(CodemanApp.prototype, {
this._runMode = mode;
try { localStorage.setItem('codeman_runMode', mode); } catch {}
this._applyRunMode();
// Sync to server for cross-device persistence
this._apiPut('/api/settings', { runMode: mode }).catch(() => {});
// Sync to server for cross-device persistence. Not for a non-admin in multi-user mode,
// where settings.json is server-wide and its PUT admin-only: the mode is kept as their own
// on this device and wins over the server's on the next load (_rememberOwnSetting,
// settings-ui.js). A PUT refused that way (sent before the role was known) marks the page
// read-only for the writes after it (_noteSettingsWriteRefusal).
if (this._settingsReadOnly?.()) {
this._rememberOwnSetting?.('runMode', mode);
} else {
this._apiPut('/api/settings', { runMode: mode })
.then((res) => this._noteSettingsWriteRefusal?.(res))
.catch(() => {});
}
// Close menu
document.getElementById('runModeMenu')?.classList.remove('active');
},
@@ -1919,12 +1955,16 @@ Object.assign(CodemanApp.prototype, {
},
async runClaude(options = {}) {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// `options.worktree` ({ caseName, name, path }, from the worktree modal): the session runs in
// that git worktree of the case and its tab is named after the worktree.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
const nameBase = wt ? wt.name : caseName;
const tabCount = this._readTabCount(options.count);
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} Claude session(s) in ${caseName}...`
`Starting ${tabCount} Claude session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...`
);
// Focus terminal NOW, in the synchronous user-gesture context (button click).
// iOS Safari ignores programmatic focus() after any await, so this must happen
@@ -1933,25 +1973,32 @@ Object.assign(CodemanApp.prototype, {
this.terminal.focus();
try {
// Get case path first
const caseRes = await fetch(`/api/cases/${caseName}`);
const caseLookup = await caseRes.json();
let caseData = caseLookup?.data ?? {};
let caseData;
if (wt) {
// A worktree is local by construction (the server refuses one for a remote or Docker
// case) and its folder came from the worktree list: no case lookup, never a scaffold.
caseData = { path: wt.path, location: 'local' };
} else {
// Get case path first
const caseRes = await fetch(`/api/cases/${caseName}`);
const caseLookup = await caseRes.json();
caseData = caseLookup?.data ?? {};
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName, description: '' })
});
const createCaseData = await createCaseRes.json();
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
// API returns { success, data: { case: { name, path } } }
caseData = createCaseData.data.case;
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName, description: '' })
});
const createCaseData = await createCaseRes.json();
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
// API returns { success, data: { case: { name, path } } }
caseData = createCaseData.data.case;
}
}
const workingDir = caseData.path;
@@ -2024,8 +2071,8 @@ Object.assign(CodemanApp.prototype, {
let firstSessionId = null;
// Find the highest existing w-number for THIS case to avoid duplicates
const startNumber = this._nextCaseSessionStartNumber(caseName);
// Find the highest existing w-number for THIS case (or worktree) to avoid duplicates
const startNumber = this._nextCaseSessionStartNumber(nameBase);
// Get global Ralph tracker setting
const ralphEnabled = this.isRalphTrackerEnabledByDefault();
@@ -2033,7 +2080,7 @@ Object.assign(CodemanApp.prototype, {
// Create all sessions in parallel for speed
const sessionNames = [];
for (let i = 0; i < tabCount; i++) {
sessionNames.push(`w${startNumber + i}-${caseName}`);
sessionNames.push(`w${startNumber + i}-${nameBase}`);
}
// Build env overrides from global + case settings (case overrides global)
@@ -2140,40 +2187,49 @@ Object.assign(CodemanApp.prototype, {
},
async runShell(options = {}) {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// `options.worktree`: see runClaude.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
const nameBase = wt ? wt.name : caseName;
// Run Shell reads the toolbar's one instance stepper, like every other run*();
// its own second `− 1 +` group (#shellCount) was removed (#428).
const shellCount = this._readTabCount(options.count);
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${shellCount} Shell session(s) in ${caseName}...`,
`Starting ${shellCount} Shell session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...`,
'1;33'
);
try {
// Get the case path
const caseRes = await fetch(`/api/cases/${caseName}`);
const caseLookup = await caseRes.json();
let caseData = caseLookup?.data ?? {};
let caseData;
if (wt) {
// Local by construction, folder from the worktree list (see runClaude).
caseData = { path: wt.path, location: 'local' };
} else {
// Get the case path
const caseRes = await fetch(`/api/cases/${caseName}`);
const caseLookup = await caseRes.json();
caseData = caseLookup?.data ?? {};
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName, description: '' })
});
const createCaseData = await createCaseRes.json();
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
// API returns { success, data: { case: { name, path } } }
caseData = createCaseData.data.case;
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName, description: '' })
});
const createCaseData = await createCaseRes.json();
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
// API returns { success, data: { case: { name, path } } }
caseData = createCaseData.data.case;
}
}
const selectedCase = (this.cases || []).find(c => c.name === caseName);
const selectedCase = wt ? null : (this.cases || []).find(c => c.name === caseName);
const isRemoteCase =
caseData.location === 'remote' ||
caseData.location === 'docker' ||
@@ -2206,13 +2262,13 @@ Object.assign(CodemanApp.prototype, {
return;
}
// Find the highest existing s-number for THIS case to avoid duplicates
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
// Find the highest existing s-number for THIS case (or worktree) to avoid duplicates
const startNumber = this._nextCaseSessionStartNumber(nameBase, 's');
// Create all shell sessions in parallel
const sessionNames = [];
for (let i = 0; i < shellCount; i++) {
sessionNames.push(`s${startNumber + i}-${caseName}`);
sessionNames.push(`s${startNumber + i}-${nameBase}`);
}
// Step 1: Create all sessions in parallel
@@ -2331,16 +2387,19 @@ Object.assign(CodemanApp.prototype, {
buildConfig: () => null,
});
if (!entry) throw new Error(`Unknown run mode: ${mode}`);
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// `options.worktree`: see runClaude. Quick-start resolves it server-side (`worktree`), so the
// session starts through the same path as any other, only in the worktree's folder.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
// Remote/docker cases run the CLI on the OTHER side — the local status
// probe and the local-only config/env below don't apply (quick-start
// rejects them for remote cases).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const _runLoc = wt ? 'local' : (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const tabCount = this._readTabCount(options.count);
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} ${entry.label} session(s) in ${caseName}...`
`Starting ${tabCount} ${entry.label} session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...`
);
// Focus in sync gesture context (see runClaude comment)
this.terminal.focus();
@@ -2369,13 +2428,15 @@ Object.assign(CodemanApp.prototype, {
// into `claude --settings`); none of these CLIs has an /effort. Each of
// the eight bodies this launcher replaced carried that rule as a comment.
const firstSessionId = await this._launchQuickStartInstances(
caseName,
// Only names the tabs (`w<n>-<name>`): a worktree session is named after the worktree.
wt ? wt.name : caseName,
tabCount,
entry.label,
(sessionName) => ({
caseName,
mode,
sessionName,
...(wt ? { worktree: wt.name } : {}),
...(options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}),
...(isRemote ? {} : {
...(entry.buildConfig(globalSettings) || {}),
@@ -4833,6 +4894,16 @@ Object.assign(CodemanApp.prototype, {
},
async saveCaseOrder(order) {
// Multi-user non-admin: the case order is ONE server-wide list (settings.json caseOrder)
// and PUT /api/cases/order is admin-only (403), so the move is not sent: say why, and put
// the list back the way the server orders it.
if (this._settingsReadOnly?.()) {
this.showToast('Server-wide settings can only be changed by an admin', 'warning');
const select = document.getElementById('quickStartCase');
await this.loadQuickStartCases(select?.value);
this.renderCaseManageList();
return;
}
try {
await fetch('/api/cases/order', {
method: 'PUT',
@@ -5007,6 +5078,404 @@ Object.assign(CodemanApp.prototype, {
// Remove animation class after it plays
setTimeout(() => modal.classList.remove('from-mobile'), 300);
},
// ═══════════════════════════════════════════════════════════════
// Git worktrees of a case (src/git-worktrees.ts, routes/worktree-routes.ts)
// ═══════════════════════════════════════════════════════════════
//
// One dialog per case: its worktrees (Open, Remove), and a form for a new one that starts a
// session in it at once. Sessions start through run() with `options.worktree`, so every run
// mode keeps its own launch path (POST /api/sessions for claude and shell, quick-start with
// `worktree` for the rest). Everything git or the user named (worktree and branch names, paths,
// git's own refusals) renders through textContent and carries data-i18n-skip.
/** Open the worktree dialog for `caseName` (default: the case selected in the toolbar). */
openWorktreeModal(caseName) {
const modal = document.getElementById('worktreeModal');
const name = caseName || document.getElementById('quickStartCase')?.value || '';
if (!modal || !name) return;
this._worktreeCase = name;
this._worktreeData = null;
this._worktreeRemoveConfirm = null;
this._worktreeRemoveError = null;
const caseLabel = document.getElementById('worktreeModalCase');
if (caseLabel) caseLabel.textContent = name;
for (const id of ['worktreeName', 'worktreeBranch', 'worktreeBase']) {
const input = document.getElementById(id);
if (input) input.value = '';
}
this._setWorktreeFormError('');
this._fillWorktreeModeSelect();
this._renderWorktreeList();
modal.classList.add('active');
void this._loadWorktrees();
// Not on a touch device: focusing the field raises the on-screen keyboard over the list.
const touch = typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice?.();
if (!touch) setTimeout(() => document.getElementById('worktreeName')?.focus?.(), 50);
},
/** The phone case sheet's Worktrees button: the selected case's worktrees. */
showWorktreesFromMobile() {
this.closeMobileCasePicker();
this.openWorktreeModal();
},
closeWorktreeModal() {
const modal = document.getElementById('worktreeModal');
// Called on every Escape (app.js): a no-op unless the dialog is open.
if (!modal || !modal.classList.contains('active')) return;
modal.classList.remove('active');
this._worktreeCase = null;
this._worktreeLoadSeq = (this._worktreeLoadSeq || 0) + 1;
},
_worktreesUrl(caseName, suffix = '') {
return `/api/cases/${encodeURIComponent(caseName)}/worktrees${suffix}`;
},
/** A JSON call that keeps the server's error text, which `_apiJson()` drops. */
async _worktreeRequest(path, opts = {}) {
const res = await this._api(path, opts);
if (!res) return { ok: false, error: 'Codeman did not answer.' };
let body = null;
try {
body = await res.json();
} catch {
/* an empty or non-JSON body: the status says enough */
}
if (res.ok && body?.success !== false) return { ok: true, data: body?.data ?? body };
return { ok: false, status: res.status, error: body?.error || `Request failed (${res.status})` };
},
async _loadWorktrees() {
const caseName = this._worktreeCase;
if (!caseName) return;
const seq = (this._worktreeLoadSeq = (this._worktreeLoadSeq || 0) + 1);
this._setWorktreeStatus('Loading worktrees…');
const r = await this._worktreeRequest(this._worktreesUrl(caseName));
if (seq !== this._worktreeLoadSeq || caseName !== this._worktreeCase) return;
this._worktreeData = r.ok ? r.data : null;
this._setWorktreeStatus(r.ok ? '' : r.error, !r.ok);
// A case that cannot list worktrees (not a repository, a Docker or remote case) cannot get one.
const createBtn = document.getElementById('worktreeCreateBtn');
if (createBtn) createBtn.disabled = !r.ok;
this._renderWorktreeList();
},
_setWorktreeStatus(text, isError = false) {
const el = document.getElementById('worktreeStatus');
if (!el) return;
el.textContent = text || '';
el.hidden = !text;
el.classList.toggle('worktree-status-error', !!isError);
},
_setWorktreeFormError(text) {
const el = document.getElementById('worktreeFormError');
if (!el) return;
el.textContent = text || '';
el.hidden = !text;
},
/** Every enabled, installed run mode, the shell last; the Run button's mode preselected. */
_fillWorktreeModeSelect() {
const select = document.getElementById('worktreeMode');
if (!select) return;
// Every launchable registry entry (agents and the shell), as data: no id is branched on here.
const modes = [];
for (const cli of registryCliCatalog()) {
if (cli.enabled && cli.available !== false) modes.push({ id: cli.id, label: cli.label });
}
if (modes.length === 0) {
// A cached page from before the registry catalogue: the built-in modes.
modes.push({ id: 'claude', label: 'Claude Code' });
for (const [id, entry] of Object.entries(RUN_MODE_LAUNCH)) modes.push({ id, label: entry.label });
modes.push({ id: 'shell', label: 'Terminal / Shell' });
}
select.replaceChildren(
...modes.map((m) => {
const option = document.createElement('option');
option.value = m.id;
option.textContent = m.label;
return option;
})
);
const current = this._runMode || 'claude';
select.value = modes.some((m) => m.id === current) ? current : modes[0].id;
},
_selectedWorktreeMode() {
return document.getElementById('worktreeMode')?.value || this._runMode || 'claude';
},
_renderWorktreeList() {
const list = document.getElementById('worktreeList');
if (!list) return;
list.replaceChildren();
const data = this._worktreeData;
if (!data) return;
for (const wt of data.worktrees) list.appendChild(this._buildWorktreeRow(wt));
if (!data.worktrees.some((wt) => wt.managed)) {
const empty = document.createElement('div');
empty.className = 'worktree-empty';
empty.textContent = 'No worktrees yet. Create one below.';
list.appendChild(empty);
}
},
_buildWorktreeRow(wt) {
const row = document.createElement('div');
row.className = 'worktree-row' + (wt.managed ? ' worktree-row--managed' : '');
const head = document.createElement('div');
head.className = 'worktree-row-head';
const label = document.createElement('span');
label.className = 'worktree-row-name';
label.setAttribute('data-i18n-skip', '');
label.textContent = wt.managed ? `⑂ ${wt.name}` : wt.path.split('/').pop() || wt.path;
head.appendChild(label);
const badge = (text, extra = '', skip = false) => {
const b = document.createElement('span');
b.className = `worktree-badge${extra ? ` ${extra}` : ''}`;
if (skip) b.setAttribute('data-i18n-skip', '');
b.textContent = text;
head.appendChild(b);
};
if (wt.branch) badge(wt.branch, 'worktree-badge-branch', true);
else if (wt.detached) badge('detached HEAD', 'worktree-badge-branch');
if (wt.main) badge('main checkout');
else if (!wt.managed) badge('not created by Codeman', 'worktree-badge-dim');
if (wt.locked) badge('locked', 'worktree-badge-warn');
if (wt.prunable) badge('missing', 'worktree-badge-warn');
if (wt.sessionCount > 0) {
badge(`${wt.sessionCount} session${wt.sessionCount === 1 ? '' : 's'}`, 'worktree-badge-live');
}
if (wt.managed) {
const actions = document.createElement('span');
actions.className = 'worktree-row-actions';
// A worktree whose folder is gone (`prunable`) cannot host a session, but Remove still
// clears git's record of it, which is what frees its name for a new worktree.
if (!wt.prunable) {
const open = document.createElement('button');
open.type = 'button';
open.className = 'wt-btn wt-btn-sm';
open.textContent = 'Open';
open.title = 'Start a session in this worktree';
open.addEventListener('click', () => void this.openWorktreeSession(wt.name));
actions.appendChild(open);
}
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'wt-btn wt-btn-sm wt-btn-danger';
remove.textContent = 'Remove…';
remove.addEventListener('click', () => this.confirmWorktreeRemoval(wt.name));
actions.appendChild(remove);
head.appendChild(actions);
}
row.appendChild(head);
// A managed worktree's folder is always `.claude/worktrees/<name>` of the case: say just that,
// so the long repository prefix (already on the main row) is not repeated on every row.
const path = document.createElement('div');
path.className = 'worktree-row-path';
path.setAttribute('data-i18n-skip', '');
path.textContent = wt.managed ? `.claude/worktrees/${wt.name}` : this._shortenHomePath(wt.path);
path.title = wt.path;
row.appendChild(path);
if (wt.managed && this._worktreeRemoveConfirm === wt.name) row.appendChild(this._buildWorktreeRemoveConfirm(wt));
return row;
},
_buildWorktreeRemoveConfirm(wt) {
const box = document.createElement('div');
box.className = 'worktree-remove-confirm';
const note = document.createElement('p');
note.className = 'worktree-remove-note';
note.textContent = wt.prunable
? 'Its folder is gone. Removing it clears the record git keeps of it, so the name can be used again.'
: 'git worktree remove refuses while the worktree has uncommitted or untracked files. Codeman never forces it.';
box.appendChild(note);
let checkbox = null;
if (wt.branch) {
const lbl = document.createElement('label');
lbl.className = 'worktree-remove-branch';
checkbox = document.createElement('input');
checkbox.type = 'checkbox';
const text = document.createElement('span');
text.textContent = 'Also delete its branch (git branch -d keeps a branch that is not merged)';
lbl.append(checkbox, text);
box.appendChild(lbl);
}
if (this._worktreeRemoveError?.name === wt.name) {
const err = document.createElement('pre');
err.className = 'worktree-remove-error';
err.setAttribute('data-i18n-skip', '');
err.textContent = this._worktreeRemoveError.text;
box.appendChild(err);
}
const buttons = document.createElement('div');
buttons.className = 'worktree-remove-buttons';
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.className = 'wt-btn wt-btn-sm';
cancel.textContent = 'Cancel';
cancel.addEventListener('click', () => this.cancelWorktreeRemoval());
const confirm = document.createElement('button');
confirm.type = 'button';
confirm.className = 'wt-btn wt-btn-sm wt-btn-danger';
confirm.textContent = 'Remove worktree';
confirm.addEventListener('click', async () => {
confirm.disabled = true;
try {
await this.removeWorktreeFromModal(wt.name, !!checkbox?.checked);
} finally {
confirm.disabled = false;
}
});
buttons.append(cancel, confirm);
box.appendChild(buttons);
return box;
},
confirmWorktreeRemoval(name) {
this._worktreeRemoveConfirm = name;
this._worktreeRemoveError = null;
this._renderWorktreeList();
},
cancelWorktreeRemoval() {
this._worktreeRemoveConfirm = null;
this._worktreeRemoveError = null;
this._renderWorktreeList();
},
async removeWorktreeFromModal(name, deleteBranch) {
const caseName = this._worktreeCase;
if (!caseName) return;
const r = await this.removeCaseWorktree(caseName, name, deleteBranch);
if (caseName !== this._worktreeCase) return;
if (!r.ok) {
this._worktreeRemoveError = { name, text: r.error };
this._renderWorktreeList();
return;
}
this._worktreeRemoveConfirm = null;
this._worktreeRemoveError = null;
await this._loadWorktrees();
},
/** DELETE one worktree (never forced); shared by the dialog and Close Session. Toasts the outcome. */
async removeCaseWorktree(caseName, name, deleteBranch) {
const query = deleteBranch ? '?deleteBranch=true' : '';
const r = await this._worktreeRequest(this._worktreesUrl(caseName, `/${encodeURIComponent(name)}${query}`), {
method: 'DELETE',
});
if (r.ok) {
const removed = r.data?.removed || { name };
if (removed.branchError) {
this.showToast?.(
`Worktree ${removed.name} removed. Branch ${removed.branch} kept: ${removed.branchError}`,
'warning'
);
} else if (removed.branchDeleted) {
this.showToast?.(`Worktree ${removed.name} and branch ${removed.branch} removed`, 'success');
} else {
this.showToast?.(`Worktree ${removed.name} removed`, 'success');
}
}
return r;
},
async createWorktreeFromModal() {
const caseName = this._worktreeCase;
if (!caseName) return;
const name = (document.getElementById('worktreeName')?.value || '').trim();
const branch = (document.getElementById('worktreeBranch')?.value || '').trim();
const base = (document.getElementById('worktreeBase')?.value || '').trim();
if (!WORKTREE_NAME_RE.test(name) || name.includes('..') || /\.(lock)?$/i.test(name)) {
this._setWorktreeFormError(
'Name: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 64 characters.'
);
return;
}
this._setWorktreeFormError('');
const mode = this._selectedWorktreeMode();
const btn = document.getElementById('worktreeCreateBtn');
if (btn) btn.disabled = true;
try {
const r = await this._worktreeRequest(this._worktreesUrl(caseName), {
method: 'POST',
body: { name, ...(branch ? { branch } : {}), ...(base ? { base } : {}) },
});
if (caseName !== this._worktreeCase) return;
if (!r.ok) {
this._setWorktreeFormError(r.error);
return;
}
const created = r.data.worktree;
this.showToast?.(`Worktree ${created.name} created on branch ${created.branch}`, 'success');
this.closeWorktreeModal();
await this._launchInWorktree({ caseName, name: created.name, path: created.path }, mode);
} finally {
if (btn) btn.disabled = false;
}
},
async openWorktreeSession(name) {
const caseName = this._worktreeCase;
const wt = this._worktreeData?.worktrees?.find((w) => w.managed && w.name === name);
if (!caseName || !wt) return;
const mode = this._selectedWorktreeMode();
this.closeWorktreeModal();
await this._launchInWorktree({ caseName, name, path: wt.path }, mode);
},
/**
* Start ONE session of `mode` in a worktree through run(), the Run button's own entry (its
* launch lock, its per-mode paths), with the same temporary `_runMode` swap the custom-model
* entries use, so the Run button's chosen mode is untouched afterwards.
*/
async _launchInWorktree(worktree, mode) {
if (this._runInFlight) {
this.showToast?.('A launch is already in progress', 'info');
return;
}
const previous = this._runMode;
this._runMode = mode || previous;
try {
await this.run({ count: 1, worktree });
} finally {
this._runMode = previous;
}
},
/** The Codeman-managed worktree a session runs in, or null (see managedWorktreeOfDir). */
_managedWorktreeOfSession(session) {
if (!session || session.remote || session.docker) return null;
return managedWorktreeOfDir(session.workingDir, this.cases);
},
/**
* Close Session's "Kill & remove worktree", once the session is gone. git's refusal (a dirty
* tree) is the answer: the worktree stays, and the toast says why and opens the dialog.
*/
async _removeWorktreeAfterClose(wt, deleteBranch) {
const r = await this.removeCaseWorktree(wt.caseName, wt.name, deleteBranch);
if (r.ok) {
if (this._worktreeCase === wt.caseName) void this._loadWorktrees();
return;
}
this.showToast?.(`Worktree ${wt.name} was kept: ${r.error}`, 'error', {
duration: 15000,
action: { label: 'Worktrees…', onClick: () => this.openWorktreeModal(wt.caseName) },
});
},
});
Object.defineProperty(CodemanApp.prototype, 'runMode', {
+402 -37
View File
@@ -35,7 +35,11 @@ Object.assign(CodemanApp.prototype, {
this.setPendingHook(data.sessionId, 'permission_prompt');
}
const toolInfo = data.tool ? `${data.tool}${data.command ? ': ' + data.command : data.file ? ': ' + data.file : ''}` : '';
this._notifySession(data.sessionId, 'critical', 'hook-permission', 'Permission Required', toolInfo || 'Claude needs tool approval to continue');
// A dialog read off a hook-less CLI's screen (codex; `source: 'screen'`, see
// src/web/screen-blocked.ts) carries a server-built message naming that CLI, so the
// notification does not claim it is Claude asking.
const screenMessage = data.source === 'screen' && data.message ? data.message : '';
this._notifySession(data.sessionId, 'critical', 'hook-permission', 'Permission Required', toolInfo || screenMessage || 'Claude needs tool approval to continue');
},
_onHookElicitationDialog(data) {
@@ -369,6 +373,39 @@ Object.assign(CodemanApp.prototype, {
return select?.value.trim() || '';
},
/**
* Announce a new release once per browser: one toast naming its headline changes.
* The notes come from /whats-new.json, which `npm run version-packages` regenerates from
* CHANGELOG.md for each release. A browser that has never seen any version just records the
* current one, so a fresh install is not greeted with "what's new".
*/
async checkWhatsNew(runningVersion) {
if (this._whatsNewChecked) return;
this._whatsNewChecked = true;
const KEY = 'codeman-whats-new-seen';
let seen = null;
try { seen = localStorage.getItem(KEY); } catch { /* storage blocked: skip the announcement */ return; }
if (seen === runningVersion) return;
const remember = () => { try { localStorage.setItem(KEY, runningVersion); } catch { /* ignore */ } };
if (!seen) { remember(); return; }
try {
const res = await fetch('/whats-new.json', { cache: 'no-cache' });
if (!res.ok) return;
const notes = await res.json();
if (notes?.version !== runningVersion || !Array.isArray(notes.highlights) || !notes.highlights.length) return;
const shown = notes.highlights.slice(0, 3).join(' · ');
const more = notes.highlights.length > 3 ? ` (+${notes.highlights.length - 3} more)` : '';
remember();
this.showToast(`Codeman updated to v${runningVersion}: ${shown}${more}`, 'info', {
duration: 20000,
action: {
label: "See what's new",
onClick: () => window.open(`https://github.com/Ark0N/Codeman/releases/tag/codeman%40${runningVersion}`, '_blank', 'noopener'),
},
});
} catch { /* offline or malformed: try again next load */ }
},
openAppSettings() {
// Load current settings
const settings = this.loadAppSettingsFromStorage();
@@ -674,6 +711,10 @@ Object.assign(CodemanApp.prototype, {
// Activate focus trap
this.activeFocusTrap = new FocusTrap(modal);
this.activeFocusTrap.activate();
// Multi-user non-admin only: remember what the form shows now, so Save can tell a real
// change to a server-wide setting from an untouched one (see the method).
this._captureSettingsFormBaseline();
},
/**
@@ -1843,8 +1884,12 @@ Object.assign(CodemanApp.prototype, {
regenBtn.textContent = 'Regenerate QR';
regenBtn.style.cssText = 'margin-top:8px;padding:4px 12px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:4px;color:var(--text-secondary);cursor:pointer;font-size:11px';
regenBtn.onclick = () => {
// A refusal is not a success: in multi-user mode this is admin-only (403).
fetch('/api/tunnel/qr/regenerate', { method: 'POST' })
.then(() => this.showToast('QR code regenerated', 'success'))
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this.showToast('QR code regenerated', 'success');
})
.catch(() => this.showToast('Failed to regenerate QR', 'error'));
};
const card = container.parentElement;
@@ -1939,6 +1984,11 @@ Object.assign(CodemanApp.prototype, {
async toggleTunnelFromWelcome() {
const btn = document.getElementById('welcomeTunnelBtn');
if (!btn) return;
// The tunnel is a server-wide setting (PUT /api/settings), admin-only in multi-user mode.
if (this._settingsReadOnly()) {
this.showToast('Server-wide settings can only be changed by an admin', 'warning');
return;
}
const isActive = btn.classList.contains('active');
btn.disabled = true;
try {
@@ -2265,12 +2315,22 @@ Object.assign(CodemanApp.prototype, {
async _handleTunnelEnableRefusal(res) {
if (!res || res.ok) return false;
let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.';
let errorCode = '';
try {
const body = await res.json();
if (body && body.error) message = body.error;
if (body && typeof body.errorCode === 'string') errorCode = body.errorCode;
} catch {
/* non-JSON body — use the default message */
}
// A non-admin in multi-user mode: PUT /api/settings is admin-only there (403 FORBIDDEN).
// That is not the no-password refusal below, so no security prompt and no retry. The
// callers skip the PUT for a known non-admin; this covers a role that had not resolved yet.
if (res.status === 403 && errorCode === 'FORBIDDEN') {
this._dismissTunnelConnecting?.();
this.showToast('Server-wide settings can only be changed by an admin', 'warning');
return true;
}
// 403 = the no-password safety refusal (COD-55). Warn loudly and let the
// operator acknowledge the risk; on confirm, retry with explicit acknowledgment.
if (res.status === 403) {
@@ -2319,6 +2379,12 @@ Object.assign(CodemanApp.prototype, {
},
async _tunnelPanelToggle(enable) {
// The tunnel is a server-wide setting (PUT /api/settings), admin-only in multi-user mode.
if (this._settingsReadOnly()) {
this.showToast('Server-wide settings can only be changed by an admin', 'warning');
this.closeTunnelPanel();
return;
}
try {
const res = await fetch('/api/settings', {
method: 'PUT',
@@ -2655,6 +2721,13 @@ Object.assign(CodemanApp.prototype, {
},
};
// One-shot form read for _captureSettingsFormBaseline(): hand back what the form
// shows, before anything below is checked, stored, applied or sent.
if (this._readSettingsFormOnce === true) {
this._readSettingsFormOnce = false;
return settings;
}
// SettingsUpdateSchema is .strict() and checks codexModel with this same
// pattern, so one bad character 400s the WHOLE settings PUT while the toast
// still says "Settings saved". Refuse it here, before anything is persisted.
@@ -2677,6 +2750,14 @@ Object.assign(CodemanApp.prototype, {
// general-settings DOM), so the fresh rebuild would drop them on every save.
if (_prev.shortcutOverrides !== undefined) settings.shortcutOverrides = _prev.shortcutOverrides;
// Multi-user mode, signed in as a non-admin: settings.json is one file for the whole
// server and PUT /api/settings answers 403 (system-routes.ts), so nothing is sent
// below. The per-device keys still save here exactly as before; every SYNCED key
// keeps the server's value, which the next page load would put back anyway, so this
// device never shows a server-wide change that did not happen.
const settingsReadOnly = this._settingsReadOnly();
const syncedChangeRefused = settingsReadOnly && this._keepServerSyncedSettings(settings, _prev);
// Save to localStorage
this.saveAppSettingsToStorage(settings);
this._updateLocalEchoState();
@@ -2897,18 +2978,31 @@ Object.assign(CodemanApp.prototype, {
let webhookError = '';
let serverSaved = false;
try {
const res = await this._apiPut('/api/settings', {
// A non-admin's save is not sent at all (see settingsReadOnly above): the server
// would refuse it with a 403, the plan-usage collection flip included.
const res = settingsReadOnly ? null : await this._apiPut('/api/settings', {
...serverSettings,
...(_chipFlip !== undefined ? { showPlanUsageLimits: _chipFlip } : {}),
notificationPreferences: notifPrefsToSave,
voiceSettings,
});
// Multi-user mode, a non-admin whose role was not known when this save started
// (/api/me had not answered, or failed and admin-ui.js fell back to a single-user
// admin): the server refused the PUT as admin-only. End it the way a known non-admin's save
// ends: every synced key back to the server's value on this device, and the one admin
// toast below instead of "Settings saved". The page is read-only from here on.
const refusedAsNonAdmin = !settingsReadOnly && (await this._noteSettingsWriteRefusal(res));
if (refusedAsNonAdmin) {
this._keepServerSyncedSettings(settings, _prev);
this.saveAppSettingsToStorage(settings);
}
// COD-55: the server refuses an unauthenticated public tunnel with a 403 — which
// rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle
// (in the UI + localStorage) so it doesn't look enabled. Other settings persisted
// to localStorage above still apply locally.
if (settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
if (!refusedAsNonAdmin && settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
settings.tunnelEnabled = false;
this.saveAppSettingsToStorage(settings);
const cb = document.getElementById('appSettingsTunnelEnabled');
@@ -2921,8 +3015,9 @@ Object.assign(CodemanApp.prototype, {
// evidence the server kept the flags the Apply refresh below reads.
serverSaved = !!res?.ok;
// Save model configuration separately
await this.saveModelConfigFromSettings();
// Save model configuration separately. For a non-admin it is not sent either, and the
// answer says whether this save would have changed it.
const modelConfigRefused = (await this.saveModelConfigFromSettings()) === true;
// The webhook is server state in its own 0600 file (its URL is a secret, kept out of
// settings.json), so like the model config above it is saved beside the settings PUT, not in
@@ -2931,12 +3026,18 @@ Object.assign(CodemanApp.prototype, {
webhookError = this._webhookPending() ? await this.saveWebhook() : '';
if (webhookError) {
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
} else if (syncedChangeRefused || modelConfigRefused || refusedAsNonAdmin) {
// ONE toast for the whole save, and only when the user really changed a server-wide
// setting: a non-admin saving a font size hears "Settings saved" as everyone does.
// A refused PUT always gets it: the server said this user is not an admin.
this.showToast('Saved on this device. Server-wide settings can only be changed by an admin.', 'warning');
} else {
this.showToast(keepOpen ? 'Settings applied' : 'Settings saved', 'success');
}
// Show tunnel-specific feedback if toggled on
if (settings.tunnelEnabled) {
// Show tunnel-specific feedback if toggled on (never for a non-admin, whose save
// starts nothing: the box only shows what the server already has)
if (settings.tunnelEnabled && !settingsReadOnly && !refusedAsNonAdmin) {
this.showToast('Tunnel starting — QR code will appear when ready...', 'info');
}
} catch (err) {
@@ -2962,7 +3063,9 @@ Object.assign(CodemanApp.prototype, {
// gestureControlEnabled from settings.json), so a change only takes effect on
// reload. Reload when it actually changed — the server PUT above already
// persisted the new value.
if (settings.gestureControlEnabled !== _prevGestureEnabled) {
// `?? false`: a non-admin's save puts a synced key the server never stored back to
// absent (_keepServerSyncedSettings), and absent means off, as in _prevGestureEnabled.
if ((settings.gestureControlEnabled ?? false) !== _prevGestureEnabled) {
this.showToast(
settings.gestureControlEnabled ? 'Enabling gesture control — reloading…' : 'Disabling gesture control — reloading…',
'info'
@@ -2971,6 +3074,147 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* True only for a signed-in NON-ADMIN in multi-user mode, where every server-wide settings
* write is refused with 403 FORBIDDEN (PUT /api/settings, PUT /api/execution/model-config and
* PUT /api/cases/order: settings.json is one file for the whole server). Single-user mode and
* admins answer false. Read at call time, because `window.__codemanUser` resolves after page
* load (admin-ui.js fetches /api/me). Also true once the server has refused one of these
* writes as admin-only on this page (_noteSettingsWriteRefusal): before /api/me answers, and
* after it failed (admin-ui.js then falls back to a single-user admin), the role alone reads
* as writable. Other modules call it as
* `this._settingsReadOnly?.()`, so a harness that loads one of them without this file reads
* it as writable, which is what an admin gets.
*/
_settingsReadOnly() {
if (this._settingsWriteForbidden === true) return true;
// typeof guard: the load path calls this, and some vm harnesses define no `window`.
const me = (typeof window !== 'undefined' && window.__codemanUser) || {};
return !!(me.multiUser && me.role !== 'admin');
},
/**
* Whether `res` (a settings write's response) is the multi-user admin-only refusal: 403 with
* errorCode FORBIDDEN. The tunnel's no-password refusal is a 403 too, with another code, and
* is not this. When it is, the page reads as read-only from now on (_settingsReadOnly), so a
* write sent before the role was known is the last one. Reads a clone, so the caller can
* still read the body.
* @returns {Promise<boolean>}
*/
async _noteSettingsWriteRefusal(res) {
if (!res || res.status !== 403) return false;
let body = null;
try {
body = await (typeof res.clone === 'function' ? res.clone() : res).json();
} catch {
return false;
}
if (body?.errorCode !== 'FORBIDDEN') return false;
this._settingsWriteForbidden = true;
return true;
},
/**
* Where a non-admin's OWN run mode and respawn presets live (multi-user mode). Both are keys of
* the server-wide settings.json, whose PUT is admin-only, so this user's picks are kept on this
* device under their name instead (_rememberOwnSetting) and put back over the server's on every
* page load (_applyOwnSettings): loadAppSettingsFromServer() writes the server's values into
* `codeman_runMode` and `codeman-respawn-presets` each time, which used to drop them on reload.
*/
_ownSettingsKey() {
const me = (typeof window !== 'undefined' && window.__codemanUser) || {};
return `codeman:own-settings:${me.username || ''}`;
},
_readOwnSettings() {
try {
const own = JSON.parse(localStorage.getItem(this._ownSettingsKey()) || '{}');
return own && typeof own === 'object' && !Array.isArray(own) ? own : {};
} catch {
return {};
}
},
/** Keep a non-admin's own pick of a server-wide `key` on this device (see _ownSettingsKey). */
_rememberOwnSetting(key, value) {
try {
const own = this._readOwnSettings();
own[key] = value;
localStorage.setItem(this._ownSettingsKey(), JSON.stringify(own));
} catch {
/* storage unavailable: the pick lasts until the next page load */
}
},
/**
* A non-admin's own run mode and respawn presets win over the server's (see _ownSettingsKey).
* Called after the server's values are applied at load, and again on `codeman:me`, because the
* role can resolve either side of that load. A no-op for admins and single-user mode.
*/
_applyOwnSettings() {
if (!this._settingsReadOnly()) return;
const own = this._readOwnSettings();
if (Array.isArray(own.respawnPresets)) {
this._serverRespawnPresets = own.respawnPresets;
try { localStorage.setItem('codeman-respawn-presets', JSON.stringify(own.respawnPresets)); } catch {}
}
if (typeof own.runMode === 'string' && own.runMode) {
this.runMode = own.runMode;
try { localStorage.setItem('codeman_runMode', own.runMode); } catch {}
this._applyRunMode?.();
}
},
/**
* What the App Settings form showed when it opened, read the way saveAppSettings() reads it
* (its one-shot `_readSettingsFormOnce` form read), so a non-admin's save can tell whether the
* user changed a SYNCED setting. The stored blob cannot answer that: a key no admin has ever
* saved is absent from it while the form shows the default, which would read as a change on
* every save. Taken only for a non-admin; nobody else's save asks for it.
*/
_captureSettingsFormBaseline() {
this._settingsFormBaseline = null;
if (!this._settingsReadOnly()) return;
this._readSettingsFormOnce = true;
let pending;
try {
pending = this.saveAppSettings();
} finally {
// Consumed by the read itself; cleared here too in case it threw before reaching it.
this._readSettingsFormOnce = false;
}
Promise.resolve(pending)
.then((form) => {
this._settingsFormBaseline = form && typeof form === 'object' ? form : null;
})
.catch(() => {});
},
/**
* A non-admin's save in multi-user mode (saveAppSettings): put every SYNCED key of `settings`
* back to the value this device last had from the server (`prev`; deleted when it had none),
* and answer whether the form changed one since it opened. Synced means outside the per-device
* `displayKeys` merge policy of loadAppSettingsFromServer() (_displaySettingKeySet(), the one
* list both read): those are the keys the server's settings.json overwrites on every page load.
* A per-device key is left exactly as the user set it.
* @returns {boolean} true when the user changed at least one synced setting
*/
_keepServerSyncedSettings(settings, prev) {
const displayKeys = this._displaySettingKeySet();
// Without a baseline (the form was read before the role was known) the stored blob stands
// in, and a key it lacks is not counted: no toast is better than one on every save.
const baseline = this._settingsFormBaseline || prev || {};
let changed = false;
for (const key of Object.keys(settings)) {
// shortcutOverrides is carried over from `prev` already and never comes from the form.
if (displayKeys.has(key) || key === 'shortcutOverrides') continue;
if (key in baseline && JSON.stringify(settings[key]) !== JSON.stringify(baseline[key])) changed = true;
if (prev && key in prev) settings[key] = prev[key];
else delete settings[key];
}
return changed;
},
/**
* After Apply: bring the groups whose contents depend on a SAVED value up to date without
* reopening the modal. openAppSettings does the same on open; this is the part of it that
@@ -3021,10 +3265,12 @@ Object.assign(CodemanApp.prototype, {
} catch (err) {
console.warn('Failed to load model config:', err);
}
// What the form shows now, so a non-admin's save can tell whether it was changed.
this._modelConfigBaseline = JSON.stringify(this._modelConfigFromSettingsForm());
},
// Save model configuration from settings modal to server
async saveModelConfigFromSettings() {
/** The model configuration as the settings modal's form shows it (the PUT body). */
_modelConfigFromSettingsForm() {
const defaultModelEl = document.getElementById('appSettingsDefaultModel');
const showRecsEl = document.getElementById('appSettingsShowModelRecommendations');
const exploreEl = document.getElementById('appSettingsModelExplore');
@@ -3038,11 +3284,26 @@ Object.assign(CodemanApp.prototype, {
if (testEl?.value) agentTypeOverrides.test = testEl.value;
if (reviewEl?.value) agentTypeOverrides.review = reviewEl.value;
const config = {
return {
defaultModel: defaultModelEl?.value || '',
showRecommendations: showRecsEl?.checked ?? true,
agentTypeOverrides,
};
},
/**
* Save model configuration from settings modal to server.
* @returns {Promise<boolean|undefined>} true only when a non-admin in multi-user mode changed it:
* the config is a key of the server-wide settings.json and its PUT is admin-only (403), so it is
* not sent, and saveAppSettings() folds this into its one admin-only toast
*/
async saveModelConfigFromSettings() {
const config = this._modelConfigFromSettingsForm();
if (this._settingsReadOnly()) {
const baseline = this._modelConfigBaseline;
return typeof baseline === 'string' && JSON.stringify(config) !== baseline;
}
try {
await fetch('/api/execution/model-config', {
@@ -4327,6 +4588,37 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* The per-device DISPLAY keys. They are device-specific (mobile vs desktop) and must not be
* synced from the server, to avoid overriding mobile defaults: loadAppSettingsFromServer()
* seeds one from the server only when this device has no value for it. A non-admin's save in
* multi-user mode reads the same split (_keepServerSyncedSettings()), so the list lives here,
* once, and is there even when GET /api/settings failed at load.
* NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
* are NOT display keys: they control server-side behavior and must sync from server.
*/
_displaySettingKeySet() {
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language',
'terminalWheelLocalScrollback',
'autoCopySelection', 'copyStripMargin',
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
'showTabDetachButton',
'mobileOverviewEnabled',
'sessionLineageLines',
'showSplitButton',
'showTileGridButton',
'tileDetachEnabled',
]);
return displayKeys;
},
async loadAppSettingsFromServer(settingsPromise = null) {
// One-time migration: showPlanUsageLimits became a per-device display setting.
// Before this, it synced from the server, so the (separate) mobile settings blob
@@ -4352,28 +4644,9 @@ Object.assign(CodemanApp.prototype, {
if (settings) {
// Extract notification prefs before merging app settings
const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings;
// Filter out display settings — these are device-specific (mobile vs desktop)
// and should not be synced from the server to avoid overriding mobile defaults.
// NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
// are NOT display keys — they control server-side behavior and must sync from server.
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language',
'terminalWheelLocalScrollback',
'autoCopySelection', 'copyStripMargin',
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
'showTabDetachButton',
'mobileOverviewEnabled',
'sessionLineageLines',
'showSplitButton',
'showTileGridButton',
'tileDetachEnabled',
]);
// Display settings are device-specific (mobile vs desktop) and must not be synced
// from the server: see _displaySettingKeySet().
const displayKeys = this._displaySettingKeySet();
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
// the server's stored value here so it is NEVER seeded into a device that
@@ -4424,14 +4697,17 @@ Object.assign(CodemanApp.prototype, {
this._serverRespawnPresets = respawnPresets;
// Also update localStorage for offline access
localStorage.setItem('codeman-respawn-presets', JSON.stringify(respawnPresets));
} else {
// Migration: push existing localStorage presets to server
} else if (!this._settingsReadOnly()) {
// Migration: push existing localStorage presets to server. Not for a non-admin in
// multi-user mode: the presets are a server-wide setting there (admin-only PUT).
const localPresets = localStorage.getItem('codeman-respawn-presets');
if (localPresets) {
const parsed = JSON.parse(localPresets);
if (parsed.length > 0) {
this._serverRespawnPresets = parsed;
this._apiPut('/api/settings', { respawnPresets: parsed }).catch(() => {});
this._apiPut('/api/settings', { respawnPresets: parsed })
.then((res) => this._noteSettingsWriteRefusal(res))
.catch(() => {});
}
}
}
@@ -4443,6 +4719,10 @@ Object.assign(CodemanApp.prototype, {
this._applyRunMode();
}
// Multi-user non-admin: their own run mode and presets, kept on this device, win over
// the server's just applied (no-op otherwise; `codeman:me` below covers a late role).
this._applyOwnSettings();
return merged;
}
} catch (err) {
@@ -4719,6 +4999,14 @@ Object.assign(CodemanApp.prototype, {
this.showToast?.('Shortcut must include Ctrl, Cmd, or Alt', 'error');
return;
}
// Refuse a chord something else already owns: the registry dispatches the first match,
// so a duplicate would silently take over (or lose to) the other action.
const taken = this.findShortcutConflict(e, shortcutId);
if (taken) {
this.renderShortcutSettingsList();
this.showToast?.(`${this.describeShortcutEvent(e)} is already used by ${taken}`, 'error');
return;
}
const modifiers = [];
if (e.ctrlKey) modifiers.push('ctrl');
if (e.metaKey) modifiers.push('meta');
@@ -4735,7 +5023,70 @@ Object.assign(CodemanApp.prototype, {
this.renderShortcutSettingsList();
},
/** Label of the action that already owns the chord in `e`, or '' when it is free. */
findShortcutConflict(e, shortcutId) {
// Chords the keydown handlers claim before (or outside) the registry: they are fixed, so a
// rebind onto one of them would be dead or would hijack it. Alt+1-9 switch tabs by index
// (the document handler does not exclude Cmd); Ctrl/Shift+Enter insert a newline and
// Ctrl/Cmd+V pastes, and the user-bound gate sits above those branches in the xterm handler.
if (e.altKey && !e.ctrlKey && !e.shiftKey && /^Digit[1-9]$/.test(e.code || '')) {
return 'Switch to Tab N';
}
// (Alt+Shift+Enter is the tile grid's Zoom chord, a registry entry, so Alt stays free.)
if (e.key === 'Enter' && !e.altKey && (e.ctrlKey || e.shiftKey)) return 'Insert Newline';
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && String(e.key).toLowerCase() === 'v') return 'Paste';
if (typeof this.getShortcutRegistry !== 'function' || typeof this.matchesShortcutEvent !== 'function') return '';
const other = this.getShortcutRegistry().find(
(shortcut) =>
shortcut.id !== shortcutId && !shortcut.disabled && Array.isArray(shortcut.bindings) && this.matchesShortcutEvent(e, shortcut)
);
return other ? other.label : '';
},
/**
* Label of the action that already owns a chord in `shortcut`'s bindings, or ''. Used when a
* binding comes back without a capture (re-enable, Reset), where the chord may have been
* given to another action in the meantime.
*/
findShortcutBindingConflict(shortcut) {
for (const binding of shortcut?.bindings || []) {
const mods = binding.modifiers || [];
const taken = this.findShortcutConflict(
{
ctrlKey: mods.includes('ctrl'),
metaKey: mods.includes('meta'),
shiftKey: mods.includes('shift'),
altKey: mods.includes('alt'),
key: binding.key || '',
code: binding.code || '',
},
shortcut.id
);
if (taken) return taken;
}
return '';
},
describeShortcutEvent(e) {
const parts = [];
if (e.ctrlKey) parts.push('Ctrl');
if (e.metaKey) parts.push('Meta');
if (e.shiftKey) parts.push('Shift');
if (e.altKey) parts.push('Alt');
parts.push(e.key && e.key.length === 1 ? e.key.toUpperCase() : e.key || e.code);
return parts.join('+');
},
resetShortcutOverride(shortcutId) {
// Reset puts the default chord back (and re-enables the row): refuse when another action
// has taken that chord since, instead of leaving two actions on one key.
const fallback = typeof DEFAULT_SHORTCUTS !== 'undefined' ? DEFAULT_SHORTCUTS.find((d) => d.id === shortcutId) : null;
const taken = fallback ? this.findShortcutBindingConflict(fallback) : '';
if (taken) {
this.renderShortcutSettingsList();
this.showToast?.(`The default chord is already used by ${taken}`, 'error');
return;
}
const settings = this.loadAppSettingsFromStorage();
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
delete shortcutOverrides[shortcutId];
@@ -4745,6 +5096,17 @@ Object.assign(CodemanApp.prototype, {
},
toggleShortcutEnabled(shortcutId, enabled) {
if (enabled) {
// Re-enabling brings the row's chord back into play: leave it disabled when another action
// has been bound to that chord while it was off.
const shortcut = this.getShortcutRegistry?.().find((s) => s.id === shortcutId);
const taken = this.findShortcutBindingConflict(shortcut);
if (taken) {
this.renderShortcutSettingsList();
this.showToast?.(`Its chord is already used by ${taken}`, 'error');
return;
}
}
const settings = this.loadAppSettingsFromStorage();
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
shortcutOverrides[shortcutId] = { ...(shortcutOverrides[shortcutId] || {}), disabled: !enabled };
@@ -4777,6 +5139,9 @@ Object.assign(CodemanApp.prototype, {
// all, and a module-level statement that throws there fails the whole file's
// evaluation, not just this feature.
document.addEventListener?.('codeman:me', () => {
// A non-admin's own run mode and respawn presets (multi-user mode) win over the server's,
// which the settings load may already have applied before the role was known.
window.app?._applyOwnSettings?.();
window.app?._applyCustomModelAdminGate?.();
window.app?._applyCliManagementAdminGate?.();
window.app?._applyMcpSyncAdminGate?.();
+246
View File
@@ -7793,6 +7793,9 @@ html[data-skin="og"] .welcome-primary:hover {
.mobile-case-picker-footer {
padding: 12px 20px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
/* Create New Case and Worktrees share the row, half each. */
display: flex;
gap: 8px;
}
.btn-case-create-mobile {
@@ -8148,6 +8151,249 @@ html[data-skin="og"] .welcome-primary:hover {
border-top: 1px solid var(--border);
}
/* Close Session: "Kill & remove worktree" (a session in a Codeman-managed git
worktree only). The wrapper re-asserts [hidden]: it lays out as a column. */
.close-option-worktree {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.close-option-worktree[hidden] {
display: none;
}
.close-option-worktree-branch {
display: flex;
align-items: flex-start;
gap: 0.4rem;
font-size: 0.75rem;
color: var(--text-dim);
padding: 0 0.25rem;
cursor: pointer;
}
.close-option-worktree-branch input {
margin-top: 0.1rem;
}
/* Git worktrees dialog (session-ui.js openWorktreeModal). Its own small button
set: the skin block greys `.btn-toolbar.btn-primary` on non-OG skins, and this
dialog needs a primary action that reads as one on every skin. */
.modal-content.worktree-modal {
max-width: 640px;
max-height: 88vh;
display: flex;
flex-direction: column;
}
.worktree-modal > .modal-body {
overflow-y: auto;
min-height: 0;
}
.worktree-modal-case {
font-family: 'SF Mono', Monaco, monospace;
font-weight: 500;
color: var(--text-dim);
}
.worktree-intro {
margin: 0 0 0.6rem;
font-size: 0.72rem;
line-height: 1.45;
}
.worktree-status {
font-size: 0.78rem;
color: var(--text-dim);
margin-bottom: 0.6rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.worktree-status[hidden],
.worktree-form-error[hidden] {
display: none;
}
.worktree-status-error,
.worktree-form-error {
color: var(--red);
}
.worktree-form-error {
font-size: 0.75rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
margin-top: 0.25rem;
}
.worktree-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 0.9rem;
}
.worktree-row {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.65rem;
background: var(--control-bg);
}
.worktree-row--managed {
border-color: var(--border-light);
}
.worktree-row-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.45rem;
}
.worktree-row-name {
font-weight: 600;
font-size: 0.85rem;
color: var(--text);
overflow-wrap: anywhere;
}
.worktree-badge {
font-size: 0.66rem;
line-height: 1.5;
padding: 0 0.4rem;
border-radius: 999px;
border: 1px solid var(--border-light);
color: var(--text-dim);
white-space: nowrap;
}
.worktree-badge-branch {
font-family: 'SF Mono', Monaco, monospace;
color: var(--accent);
border-color: rgba(var(--accent-rgb), 0.35);
white-space: normal;
overflow-wrap: anywhere;
}
.worktree-badge-dim {
color: var(--text-muted);
}
.worktree-badge-warn {
color: var(--yellow);
border-color: var(--yellow);
}
.worktree-badge-live {
color: var(--green);
border-color: var(--green);
}
.worktree-row-actions {
display: inline-flex;
gap: 0.35rem;
margin-left: auto;
}
.worktree-row-path {
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.68rem;
color: var(--text-muted);
margin-top: 0.25rem;
overflow-wrap: anywhere;
}
.worktree-empty {
font-size: 0.75rem;
color: var(--text-muted);
padding: 0.25rem 0.1rem;
}
.worktree-remove-confirm {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px dashed var(--border-light);
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.worktree-remove-note {
margin: 0;
font-size: 0.72rem;
color: var(--text-dim);
}
.worktree-remove-branch {
display: flex;
align-items: flex-start;
gap: 0.4rem;
font-size: 0.72rem;
color: var(--text-dim);
cursor: pointer;
}
.worktree-remove-error {
margin: 0;
font-size: 0.72rem;
color: var(--red);
white-space: pre-wrap;
overflow-wrap: anywhere;
background: rgba(224, 99, 99, 0.08);
border-radius: 6px;
padding: 0.4rem 0.5rem;
}
.worktree-remove-buttons {
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.worktree-mode-row {
max-width: 320px;
}
.worktree-new {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem 0.75rem 0.4rem;
margin: 0;
min-width: 0;
}
.worktree-new > legend {
font-size: 0.72rem;
font-weight: 600;
color: var(--text-dim);
padding: 0 0.3rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.worktree-advanced {
margin-bottom: 0.5rem;
}
.worktree-advanced > summary {
cursor: pointer;
font-size: 0.75rem;
color: var(--text-dim);
margin-bottom: 0.5rem;
}
.wt-btn {
font: inherit;
font-size: 0.8rem;
padding: 0.4rem 0.85rem;
border-radius: var(--btn-radius);
border: 1px solid var(--control-border);
background: var(--control-bg);
color: var(--text);
cursor: pointer;
transition: background var(--transition-smooth), border-color var(--transition-smooth);
}
.wt-btn:hover:not(:disabled) {
background: var(--control-bg-hover);
border-color: var(--control-border-hover);
}
.wt-btn:disabled {
opacity: 0.5;
cursor: default;
}
.wt-btn-sm {
font-size: 0.72rem;
padding: 0.2rem 0.6rem;
}
.wt-btn-primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-ink);
font-weight: 600;
}
.wt-btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.wt-btn-danger {
color: var(--red);
border-color: rgba(224, 99, 99, 0.45);
}
.wt-btn-danger:hover:not(:disabled) {
background: rgba(224, 99, 99, 0.12);
border-color: var(--red);
}
.modal-session-name {
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.8rem;
+1
View File
@@ -327,6 +327,7 @@
) {
return false;
}
if (ev.type === 'keydown' && global.app?.isUserBoundShortcutEvent?.(ev)) return false;
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
return false;
}
+3
View File
@@ -749,6 +749,9 @@ Object.assign(CodemanApp.prototype, {
return false;
}
// A chord the user rebound to an app action (Settings -> Shortcuts) must not also type into the PTY.
if (ev.type === 'keydown' && this.isUserBoundShortcutEvent?.(ev)) return false;
// Command palette chord (COD-153): keep it out of the PTY. The document
// CAPTURE handler has already opened the palette by the time xterm sees
// this keydown, but its preventDefault() does NOT stop xterm — without
+13
View File
@@ -0,0 +1,13 @@
{
"version": "1.42.0",
"highlights": [
"Git worktrees for a case (#625)",
"Codex sessions turn red when Codex asks you something (#624)",
"Prompt uploads take documents (#621)",
"Previous and Next Session are rebindable (#617)",
"A note after each update (#618)",
"Multi-user mode: only admins change server-wide settings",
"Codex submits long prompts (#628)",
"New Claude sessions in an untrusted folder (#634, #635)"
]
}
+13 -9
View File
@@ -24,6 +24,7 @@ import { ApprovalAnswerSchema } from '../schemas.js';
import { parseBody, getAuthUser, canAccessOwned, findSessionOrFail } from '../route-helpers.js';
import { approvalInbox, type ApprovalItem } from '../approval-inbox.js';
import { hooksAvailableForMode, sessionHookOptions } from '../session-wait-registry.js';
import { getCli } from '../../config/cli-registry/registry.js';
import type { SessionPort } from '../ports/index.js';
/**
@@ -99,20 +100,23 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
// Throws 404 (not 403) for sessions the caller does not own, same
// no-existence-leak rule as every other session route.
const session = findSessionOrFail(ctx, item.sessionId, req);
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
}
// Alert-only items (supervised-hook CLIs such as DeepSeek Harness): the
// dialog belongs to a third-party TUI whose keystroke contract Codeman has
// not measured, so '1'/Esc would be a blind keystroke into a foreign
// composer. The item still raises the red alert and clears on the harness's
// own working/stop reports; answering happens in the terminal.
// Alert-only items: the dialog belongs to a TUI whose keystroke contract Codeman
// has not measured, so '1'/Esc would be a blind keystroke into a foreign composer.
// Two sources today: a supervised-hook CLI (DeepSeek Harness), whose item clears on
// the harness's own working/stop reports, and a dialog read off the screen of a
// hook-less CLI (codex, web/screen-blocked.ts), which clears when it leaves the
// screen and was read from text the agent writes, so it must never steer a key.
// Checked BEFORE the hooks gate below: a codex session has no hooks, and "this mode
// cannot have pending approvals" would be false about the item the caller holds.
if (item.answerInTerminal) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'DeepSeek Harness approvals must be answered in the terminal: the dialog belongs to a third-party TUI whose keystrokes Codeman cannot verify.'
`${getCli(session.mode)?.label ?? session.mode} approvals must be answered in the terminal: the dialog belongs to a TUI whose keystrokes Codeman cannot verify.`
);
}
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
}
// Re-capture the pane before aiming keystrokes at it: if the dialog was
// answered in the terminal moments ago, the digit would land in whatever
+8 -1
View File
@@ -60,6 +60,7 @@ import {
isAdmin,
isWorkingDirAllowed,
ownerFor,
requireAdmin,
resolveCasesDir,
SETTINGS_PATH,
validatePathWithinBase,
@@ -1665,7 +1666,13 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
// ========== Reorder Cases ==========
app.put('/api/cases/order', async (req): Promise<ApiResponse<{ order: string[] }>> => {
app.put('/api/cases/order', async (req, reply): Promise<ApiResponse<{ order: string[] }> | undefined> => {
// `caseOrder` is ONE list in the server-wide settings.json, read by every user's
// GET /api/cases, so a non-admin's reorder would replace everyone's order (and
// publish their case names to every user through GET /api/settings). Admin-only in
// multi-user mode, like PUT /api/settings; a per-user order would need per-user
// storage. No-op in single-user mode.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data');
try {
+1
View File
@@ -17,6 +17,7 @@ export { registerReadMyMindRoutes } from './readmymind-routes.js';
export { registerGitStatusRoutes } from './git-status-routes.js';
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
export { registerCaseRoutes } from './case-routes.js';
export { registerWorktreeRoutes } from './worktree-routes.js';
export { registerSessionRoutes } from './session-routes.js';
export { registerRespawnRoutes } from './respawn-routes.js';
export { registerRalphRoutes } from './ralph-routes.js';
+17 -11
View File
@@ -16,6 +16,7 @@ import { SseEvent } from '../sse-events.js';
import {
autoConfigureRalph,
getAuthUser,
isAdmin,
ownerFor,
resolveCasesDir,
sessionCapacityMessage,
@@ -463,20 +464,25 @@ export function registerRalphRoutes(
});
}
// Save lastUsedCase
try {
let settings: Record<string, unknown> = {};
// Save lastUsedCase. It is a key of the server-wide settings.json, readable by every
// user through GET /api/settings, so a non-admin's case name is not written there in
// multi-user mode (writes are admin-only: PUT /api/settings). isAdmin is always true
// in single-user mode.
if (isAdmin(req)) {
try {
settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
let settings: Record<string, unknown> = {};
try {
settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
} catch {
/* ignore */
}
settings.lastUsedCase = caseName;
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical
} catch {
/* ignore */
/* non-critical */
}
settings.lastUsedCase = caseName;
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical
} catch {
/* non-critical */
}
const sessionId = session.id;
+203 -57
View File
@@ -13,6 +13,7 @@ import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import { finished } from 'node:stream/promises';
import { randomBytes, randomUUID } from 'node:crypto';
import { isUtf8 } from 'node:buffer';
import { performance } from 'node:perf_hooks';
import {
ApiErrorCode,
@@ -181,6 +182,7 @@ import {
readDockerHosts,
toSessionDocker,
} from '../../docker-hosts.js';
import { resolveCaseRepository, resolveManagedWorktree } from '../../git-worktrees.js';
import { LRUMap } from '../../utils/lru-map.js';
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
@@ -323,18 +325,36 @@ export function stripInkRedrawBloat(buffer: string): string {
}
/**
* Validate image bytes against a declared extension. Sniffs the first ~12 bytes
* for a known magic-number signature. Defends against polyglots (e.g. HTML or
* SVG disguised under a `Content-Type: image/png` header) and against simple
* extension-only spoofing — both the multipart filename and the Content-Type
* are attacker-controlled, the raw bytes are not.
* The text half of the document allowlist: these are judged on their encoding
* (valid UTF-8, no NUL byte) rather than on a signature, since plain text has none.
*/
const TEXT_UPLOAD_EXTS = new Set(['.txt', '.md', '.csv', '.tsv', '.json', '.log', '.yaml', '.yml', '.xml']);
/**
* Validate upload bytes against a declared extension. Sniffs the first ~12 bytes
* for a known magic-number signature (a text file is instead required to be
* UTF-8 without a NUL byte, the one check plain text allows). Defends against
* polyglots (e.g. HTML or SVG disguised under a `Content-Type: image/png`
* header) and against simple extension-only spoofing — both the multipart
* filename and the Content-Type are attacker-controlled, the raw bytes are not.
* Fail-closed: an extension this function does not know is never a match.
*
* Signatures: https://en.wikipedia.org/wiki/List_of_file_signatures
*/
export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
if (TEXT_UPLOAD_EXTS.has(ext)) return data.length > 0 && !data.includes(0) && isUtf8(data);
if (data.length < 12) return false;
const u32be = (off: number): number => data.readUInt32BE(off);
switch (ext) {
case '.pdf':
// "%PDF-"
return u32be(0) === 0x25504446 && data[4] === 0x2d;
case '.docx':
case '.pptx':
case '.xlsx':
case '.zip':
// PK\x03\x04, the local-file header every non-empty zip (OOXML included) opens with
return u32be(0) === 0x504b0304;
case '.png':
return u32be(0) === 0x89504e47 && u32be(4) === 0x0d0a1a0a;
case '.jpg':
@@ -428,11 +448,27 @@ async function ensureUploadDir(workingDir: string): Promise<string | null> {
return uploadDir;
}
/**
* The part of an uploaded document's name that survives into the saved name,
* so `Q3 report (final).pdf` is findable as `paste-<ts>-<hex>-Q3-report-final.pdf`.
* Only `[A-Za-z0-9._-]` survive, runs of anything else become one `-`, and the
* result is capped at 40 chars; nothing in it can be a path separator or a
* second extension that matters (the allowlisted extension is appended after).
*/
function sanitizeUploadStem(filename: string): string {
const stem = basename(filename).replace(/\.[^.]*$/, '');
return stem
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/^[-.]+|[-.]+$/g, '')
.slice(0, 40);
}
/** Open a fresh, uniquely named paste file for writing. */
async function openPasteFile(imageDir: string, ext: string) {
async function openPasteFile(imageDir: string, ext: string, stem = '') {
// Date.now() collides on same-ms uploads from two tabs (last-write wins
// silently). Append 8 hex chars so concurrent pastes get distinct names.
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
// The `paste-` prefix is what the GC (paste-image-gc.ts) matches on.
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${stem ? `-${stem}` : ''}${ext}`;
const filepath = join(imageDir, filename);
// O_EXCL: refuse to overwrite (collision is impossible with random suffix,
// but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
@@ -678,6 +714,27 @@ function currentSignalFor(session: { pid: number | null; status: SessionStatus }
return signalForStatus(session.status);
}
/**
* `currentSignalFor`, answering `blocked` for a session whose pane is showing a dialog
* its CLI is blocked on (`Session.screenBlocked`, read off the screen for a CLI with no
* hook: codex) when the caller asked for it. A hook-raised `blocked` is an edge with no
* history, but this one is a level the server is still looking at, so a caller that
* asks after the dialog appeared is answered at once instead of waiting out its timeout
* for an edge that already passed. Only when `blocked` was asked for: such a pane is also
* `idle`, and a caller that asked for `idle` alone must keep getting `idle`. Used by
* `GET .../wait` and by a DUPLICATE send-and-wait, every place a wait answers from the
* session's current state.
*/
function currentSignalForWait(
session: { pid: number | null; status: SessionStatus; screenBlocked?: boolean },
dead: boolean,
until: readonly WaitSignal[]
): WaitSignal | null {
const signal = currentSignalFor(session, dead);
if (signal !== 'exit' && session.screenBlocked && until.includes('blocked')) return 'blocked';
return signal;
}
// ── Worker liveness for tmux-backed sessions ────────────────────────────────
//
// `session.pid` is the LOCAL `tmux attach` client, not the worker. Codeman sets
@@ -1998,7 +2055,9 @@ export function registerSessionRoutes(
// A DUPLICATE has no new turn coming, so it answers from the current state
// instead of blocking for a transition that already happened.
requireTransition: !duplicate,
currentSignal: duplicate ? currentSignalFor(session, workerDead) : undefined,
// The `...ForWait` variant, as on GET .../wait: a screen-read dialog the first
// delivery raised is a level, and a retry asking for `blocked` must find it.
currentSignal: duplicate ? currentSignalForWait(session, workerDead, until) : undefined,
});
} catch (err) {
if (err instanceof WaitCapacityError) {
@@ -2014,7 +2073,7 @@ export function registerSessionRoutes(
const stopDeathWatch = wantsWait ? watchForDeadWorker(ctx.mux, session, id) : () => {};
// Write input to PTY. Direct write is synchronous; writeViaMux
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
// (tmux paste + Enter) is fire-and-forget to avoid blocking the HTTP response.
// Every write here is `fromUser`: this route carries a person's prompt, or an
// agent's on their behalf, so it may name the tab (Ralph, respawn, cron and
// approvals write through the session directly and never say so).
@@ -2037,7 +2096,7 @@ export function registerSessionRoutes(
// A plain prompt (`<text>\r`) goes through the mux even when the caller did not
// ask for it: written straight into the pane it arrives as one burst, and Claude
// Code takes a long burst as a paste whose `\r` becomes a newline, so the prompt
// sat unsent (see isPlainPromptInput). The mux path types the text, presses Enter
// sat unsent (see isPlainPromptInput). The mux path pastes the text, presses Enter
// separately and arms the SubmitVerifier. An explicit `useMux: false` keeps the
// raw write for a caller that really wants it.
const autoMux = useMux === undefined && isPlainPromptInput(inputStr);
@@ -2182,7 +2241,7 @@ export function registerSessionRoutes(
abortSignal: abort.signal,
requireTransition: query.fresh === '1' || query.fresh === 'true',
// Read BEFORE awaiting: this is the state the caller is asking about.
currentSignal: currentSignalFor(session, workerIsDead(ctx.mux, session)),
currentSignal: currentSignalForWait(session, workerIsDead(ctx.mux, session), until),
});
return {
@@ -3675,6 +3734,7 @@ export function registerSessionRoutes(
agentOrigin,
customModel,
tabGroupId,
worktree,
} = parseBody(QuickStartSchema, req.body);
// Resolved ONCE here: the same value labels a case directory this request creates
@@ -3708,6 +3768,16 @@ export function registerSessionRoutes(
: (await readDockerCases(CODEMAN_CONFIG_DIR)).find(
(item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner)
);
// Worktrees are local only (src/git-worktrees.ts): refused BEFORE a remote host is woken or a
// container is touched, since neither branch below could honour it.
if (worktree && (remoteCase || dockerCase)) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
remoteCase
? 'worktree is not available for remote (SSH) cases: the repository is on another machine'
: 'worktree is not available for Docker cases: git would run on this machine over a workspace the container can write'
);
}
if (remoteCase) {
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
@@ -3949,6 +4019,27 @@ export function registerSessionRoutes(
);
}
// Where the session runs: the case folder, or one of its git worktrees. A worktree is only
// ever USED here (created through POST /api/cases/:name/worktrees), so a case that does not
// exist yet is a 404 rather than a scaffold, and the worktree must be a registered, managed
// one, every path component a real directory (src/git-worktrees.ts).
let workspacePath = resolvedCasePath;
if (worktree) {
if (localCaseState === 'absent') {
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${caseName}" not found`);
}
if (localCaseState !== 'directory') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Case path is not a folder');
}
const caseRepo = await resolveCaseRepository(resolvedCasePath, {
dockerWorkspaces: (await readDockerCases(CODEMAN_CONFIG_DIR)).map((c) => c.hostWorkspacePath).filter(Boolean),
});
if (!caseRepo.ok) return createErrorResponse(ApiErrorCode[caseRepo.code], caseRepo.message);
const managed = await resolveManagedWorktree(caseRepo.repo, worktree);
if (!managed.ok) return createErrorResponse(ApiErrorCode[managed.code], managed.message);
workspacePath = managed.path;
}
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
// non-docker cases — docker workspaces are scaffolded in their own block below)
if (localCaseState === 'absent') {
@@ -4007,9 +4098,9 @@ export function registerSessionRoutes(
// of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that
// doesn't exist on the local filesystem.
if (getCli(mode)?.capabilities.hooks === 'always') {
await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled());
await applyWorkspaceHooks(workspacePath, await ctx.getWorkspaceHooksEnabled());
} else {
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
await refreshStaleCodemanHooks(workspacePath).catch(() => {});
}
}
@@ -4021,7 +4112,7 @@ export function registerSessionRoutes(
// hostWorkspacePath is a real host dir and the skill crosses the bind mount.
const quickStartSkillDir = agentSkillDir(getCli(mode)?.capabilities);
if (!remote && quickStartSkillDir && (await ctx.getAgentSkillEnabled())) {
await injectAgentSkill(resolvedCasePath, quickStartSkillDir);
await injectAgentSkill(workspacePath, quickStartSkillDir);
}
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
@@ -4054,14 +4145,14 @@ export function registerSessionRoutes(
// docker — the docker workspace is a real host dir, so the settings file crosses
// the bind mount and the in-container claude reads it). Remote was rejected above.
if (getCli(mode)?.capabilities.model.source === 'claude-settings-file' && modelOverride !== undefined) {
await updateCaseModel(resolvedCasePath, modelOverride || null);
await updateCaseModel(workspacePath, modelOverride || null);
}
// Strip stale disk entries for keys this request is actively setting (Claude only —
// see POST /api/sessions for full rationale).
// Same chain, same replacement as the create path above: byte-identical, drift-proof.
if (!isExternalCliMode(mode) && !remote && envOverrides && Object.keys(envOverrides).length > 0) {
await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides));
await stripCaseEnvKeys(workspacePath, Object.keys(envOverrides));
}
// Create a new session with the case as working directory
@@ -4117,7 +4208,7 @@ export function registerSessionRoutes(
);
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, qsLaunchOmpConfig);
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, workspacePath, qsLaunchOmpConfig);
// Custom Model Endpoint Profiles, applied AT CREATE TIME (docs/custom-model-endpoints-plan.md)
// rather than via the dedicated restart-in-place route (POST /api/sessions/:id/custom-
@@ -4223,7 +4314,7 @@ export function registerSessionRoutes(
customModel.modelId,
qsCustomModelSessionId,
cmContextLength,
resolvedCasePath
workspacePath
);
if (!cmApplied) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
@@ -4264,7 +4355,7 @@ export function registerSessionRoutes(
const qsSessionOptions: ConstructorParameters<typeof Session>[0] = {
id: qsCustomModelSessionId,
workingDir: resolvedCasePath,
workingDir: workspacePath,
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
mux: ctx.mux,
useMux: true,
@@ -4322,7 +4413,7 @@ export function registerSessionRoutes(
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
// so the initial state already has the phrase configured (only if globally enabled)
if (getCli(mode)?.capabilities.ralph && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
autoConfigureRalph(session, resolvedCasePath, ctx);
autoConfigureRalph(session, workspacePath, ctx);
if (!session.ralphTracker.enabled) {
session.ralphTracker.enable();
session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
@@ -4386,34 +4477,40 @@ export function registerSessionRoutes(
).catch(() => {});
}
// Save lastUsedCase to settings for TUI/web sync
try {
const settingsFilePath = SETTINGS_PATH;
let settings: Record<string, unknown> = {};
// Save lastUsedCase to settings for TUI/web sync. It is a key of the server-wide
// settings.json, readable by every user through GET /api/settings, so a non-admin's
// case name is not written there in multi-user mode (writes are admin-only, like
// PUT /api/settings). isAdmin is always true in single-user mode.
if (isAdmin(req)) {
try {
settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
const settingsFilePath = SETTINGS_PATH;
let settings: Record<string, unknown> = {};
try {
settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
settings.lastUsedCase = caseName;
const dir = dirname(settingsFilePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Use async write to avoid blocking event loop
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
// Non-critical but log for debugging
console.warn('[Server] Failed to save settings (lastUsedCase):', err);
});
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
settings.lastUsedCase = caseName;
const dir = dirname(settingsFilePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Use async write to avoid blocking event loop
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
// Non-critical but log for debugging
console.warn('[Server] Failed to save settings (lastUsedCase):', err);
});
} catch (err) {
// Non-critical but log for debugging
console.warn('[Server] Failed to prepare settings update:', err);
console.warn('[Server] Failed to prepare settings update:', err);
}
}
return {
sessionId: session.id,
casePath: resolvedCasePath,
caseName,
...(worktree ? { worktree, workingDir: workspacePath } : {}),
...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}),
...(tabGroupId ? { tabLayout } : {}),
};
@@ -5350,6 +5447,12 @@ export function registerSessionRoutes(
// so an uploaded video is a type that previews. 3gp/mkv are deliberately out: they
// would upload but not preview, and a phone's camera roll produces neither.
const ALLOWED_VIDEO_EXTS = new Set([...VIDEO_ATTACHMENT_EXTENSIONS].map((e) => `.${e}`));
// Documents a user hands an agent to read: the office formats plus the text
// family. Curated on purpose, never SUPPORTED_ATTACHMENT_EXTENSIONS or
// EDITABLE_EXTENSIONS, which carry sh/py/js/html; a prompt upload is a thing
// to read, not a thing to run or serve. Each is judged on its bytes by
// imageMagicMatchesExt (signature, or UTF-8 without NUL for text).
const ALLOWED_DOCUMENT_EXTS = new Set(['.pdf', '.docx', '.pptx', '.xlsx', '.zip', ...TEXT_UPLOAD_EXTS]);
const VIDEO_MIME_EXT: Record<string, string> = {
mp4: '.mp4',
quicktime: '.mov',
@@ -5359,6 +5462,19 @@ export function registerSessionRoutes(
};
// The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
/** Write buffered upload bytes into the session's upload dir. Null: the dir is not a real directory. */
async function writeBufferedUpload(workingDir: string, ext: string, bytes: Buffer, stem = '') {
const uploadDir = await ensureUploadDir(workingDir);
if (!uploadDir) return null;
const { filename, filepath, fh } = await openPasteFile(uploadDir, ext, stem);
try {
await fh.writeFile(bytes);
} finally {
await fh.close();
}
return { path: filepath, filename };
}
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
// CSRF defense: state-changing routes must come from same origin.
// Cookies are SameSite=lax, multipart/form-data is a "simple" CORS request
@@ -5492,13 +5608,13 @@ export function registerSessionRoutes(
`Unsupported video type. Allowed: ${[...ALLOWED_VIDEO_EXTS].join(', ')}`
);
}
const imageDir = await ensureUploadDir(session.workingDir);
if (!imageDir) {
const uploadDir = await ensureUploadDir(session.workingDir);
if (!uploadDir) {
await finished(part.file.resume()).catch(() => {});
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
}
const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
const { filename, filepath, fh } = await openPasteFile(uploadDir, ext);
// Written as it arrives: a phone video is far too large to buffer the way an
// image is. The container is judged on the first 12 bytes as soon as they are
// in, so a mislabeled upload stops touching disk at its first chunk; the rest
@@ -5557,12 +5673,41 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Empty file');
}
// Dispatch mirrors the client's _promptAttachKind: an image MIME type takes
// the image path whatever the name says, a document extension (the MIME a
// phone gives a .md is anyone's guess) takes the document branch, anything
// else the image path exactly as before, HEIC sniff included. See CLAUDE.md.
const origExt = part.filename ? extname(part.filename).toLowerCase() : '';
const imageMime = /^image\//i.test(part.mimetype || '');
if (!imageMime && ALLOWED_DOCUMENT_EXTS.has(origExt)) {
if (!imageMagicMatchesExt(imageBytes, origExt)) {
// The first bytes of a text file are user content, so for text the log
// names the failure and never the bytes; a signature is safe to print.
const detail = TEXT_UPLOAD_EXTS.has(origExt)
? `reason=${imageBytes.includes(0) ? 'nul-byte' : 'not-utf8'}`
: `magic=${imageBytes.subarray(0, 12).toString('hex')}`;
console.warn(
`[paste-image] document mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${origExt} size=${imageBytes.length} ${detail}`
);
reply.code(415);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `File bytes do not match declared type ${origExt}`);
}
const saved = await writeBufferedUpload(
session.workingDir,
origExt,
imageBytes,
sanitizeUploadStem(part.filename)
);
if (!saved) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
}
return saved;
}
// Determine extension from filename or Content-Type.
let ext = '.png';
if (part.filename) {
const origExt = extname(part.filename).toLowerCase();
if (ALLOWED_IMAGE_EXTS.has(origExt)) ext = origExt;
}
if (ALLOWED_IMAGE_EXTS.has(origExt)) ext = origExt;
const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp|heic|heif)$/);
if (mimeMatch) {
const map: Record<string, string> = {
@@ -5603,6 +5748,15 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Could not convert HEIC image to JPEG');
}
} else if (!imageMagicMatchesExt(imageBytes, ext)) {
// A foreign extension with no image MIME (a script, an HTML page, a video
// without its flag) was never going to be an image: say so, rather than
// the ".png" the default extension would name. Decided only HERE, after
// the sniff, so anything the image path ever accepted by its bytes (a
// HEIC under a generic name) still is.
if (origExt && !ALLOWED_IMAGE_EXTS.has(origExt) && !imageMime) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unsupported file type: ${origExt}`);
}
// Sniff actual bytes — a polyglot HTML/PNG would otherwise pass and
// serve back with image/png MIME. Log the real header so format
// mismatches can be pinned down without a reproduce-and-guess loop. The
@@ -5614,20 +5768,12 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
}
// {workingDir}/.codeman-uploads/, see ensureUploadDir.
const imageDir = await ensureUploadDir(session.workingDir);
if (!imageDir) {
const saved = await writeBufferedUpload(session.workingDir, ext, imageBytes);
if (!saved) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
}
const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
try {
await fh.writeFile(imageBytes);
} finally {
await fh.close();
}
return { path: filepath, filename };
return saved;
});
// Returned so the server can own the registry's LIFETIME (drop state when a session is
+10 -5
View File
@@ -51,12 +51,17 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
const payload = data as RawStatuslinePayload | undefined;
// Only a CLI that has a statusline exporter at all speaks for its own model and
// prompt-cache state: both ride the SAME statusline payload the exporter POSTs, so
// both are gated on capabilities.statusLineTelemetry (the gate that also decides
// whether the exporter is injected at all, in tmux-manager._resolveStatusLine).
const session = ctx.sessions.get(sessionId);
const reportsStatusLine = !!session && !!getCli(session.mode)?.capabilities.statusLineTelemetry;
// The model the CLI says it is running, for the session headers (displayModel). It
// rides every render, so it follows an in-session `/model`; the session dedupes and
// broadcasts only a change. Only a CLI that has a statusline exporter at all may
// report one here.
const session = ctx.sessions.get(sessionId);
if (session && getCli(session.mode)?.capabilities.statusLineTelemetry) {
// broadcasts only a change.
if (session && reportsStatusLine) {
session.noteReportedModel('statusline', payload?.model?.display_name);
}
@@ -68,7 +73,7 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
// it backgrounds this POST (hooks-config.ts), so two sub-second loopback reports can
// arrive out of order; a stale one then briefly overwrites newer state until the next
// render corrects it. Accepted for a display-only readout rather than ordered here.
const promptCache = parsePromptCache(payload);
const promptCache = reportsStatusLine ? parsePromptCache(payload) : null;
if (promptCache && session?.setPromptCache(promptCache)) {
ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache });
// Persisted on change, like a model change, so a crash restores the latest report.
+81 -12
View File
@@ -17,7 +17,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } f
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { findUser, canUsernameRunPrivilegedCommands } from '../../user-store.js';
import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js';
import { getAuthUser, isAdmin, requireAdmin, canAccessOwned, revokeUserSessions } from '../route-helpers.js';
import {
ConfigUpdateSchema,
SettingsUpdateSchema,
@@ -144,6 +144,28 @@ export function resolveSpanUrl(hostHeader: string | undefined, fallbackPort = '3
return `http://localhost:${port}`;
}
/**
* What GET /api/settings answers a NON-ADMIN in multi-user mode: settings.json minus what
* belongs to the server's admin rather than to the reader. `voiceSettings.apiKey` is the
* Deepgram key an admin saved, a credential (the reader's browser seeded it into its own
* voice config and dictated on the admin's account); `caseOrder` and `lastUsedCase` name the
* admin's cases. Everything else stays readable, because the page reads the synced feature
* switches from it. Writes are admin-only there (PUT /api/settings). Exported for unit testing.
*/
export function settingsForNonAdmin(settings: unknown): unknown {
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return settings;
const out: Record<string, unknown> = { ...(settings as Record<string, unknown>) };
delete out.caseOrder;
delete out.lastUsedCase;
const voice = out.voiceSettings;
if (voice && typeof voice === 'object' && !Array.isArray(voice) && 'apiKey' in voice) {
const voiceOut: Record<string, unknown> = { ...(voice as Record<string, unknown>) };
delete voiceOut.apiKey;
out.voiceSettings = voiceOut;
}
return out;
}
export function registerSystemRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
@@ -163,12 +185,17 @@ export function registerSystemRoutes(
app.get('/api/tunnel/status', async () => ctx.tunnelManager.getStatus());
app.get('/api/tunnel/info', async () => {
app.get('/api/tunnel/info', async (req) => {
const status = ctx.tunnelManager.getStatus();
const sseClients = ctx.getSseClientCount();
const sessions: Array<{ ip: string; ua: string; createdAt: number; method: string }> = [];
// Multi-user mode: a non-admin is shown only their own logins (address and browser of
// every other user's sessions are not theirs to read), matching what their Revoke All
// can revoke (POST /api/auth/revoke). null = everyone's (admin, single-user mode).
const onlyUser = isMultiUserMode() && !isAdmin(req) ? getAuthUser(req).username : null;
if (ctx.authSessions) {
for (const [, record] of ctx.authSessions) {
if (onlyUser !== null && record.username !== onlyUser) continue;
sessions.push({ ip: record.ip, ua: record.ua, createdAt: record.createdAt, method: record.method });
}
}
@@ -300,7 +327,10 @@ export function registerSystemRoutes(
// ========== QR Regeneration ==========
app.post('/api/tunnel/qr/regenerate', async () => {
app.post('/api/tunnel/qr/regenerate', async (req, reply) => {
// Clears EVERY outstanding QR login code (each user's minted one too) and rotates the
// server's token: admin-only in multi-user mode, like the other tunnel controls.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
ctx.tunnelManager.regenerateQrToken();
return {};
});
@@ -309,6 +339,19 @@ export function registerSystemRoutes(
app.post('/api/auth/revoke', async (req) => {
const result = RevokeSessionSchema.safeParse(req.body);
// Multi-user mode: a NON-ADMIN revokes only their own login sessions. The bare form
// below clears every session on the server, so for them it used to sign out every
// user, admins included; a named token is revoked only when it is one of theirs.
if (isMultiUserMode() && !isAdmin(req)) {
const username = getAuthUser(req).username;
const token = result.success ? result.data.sessionToken : undefined;
if (token) {
if (ctx.authSessions?.peek(token)?.username === username) ctx.authSessions?.delete(token);
} else {
revokeUserSessions(ctx.authSessions, username);
}
return {};
}
if (result.success && result.data.sessionToken) {
ctx.authSessions?.delete(result.data.sessionToken);
} else {
@@ -327,6 +370,8 @@ export function registerSystemRoutes(
// panels can be dragged across the physical monitor seam. macOS only; needs
// the one-time "Displays have separate Spaces" OFF prerequisite (see script).
app.post('/api/system/span-displays', async (req, reply) => {
// Launches a browser window on the SERVER's desktop: admin-only in multi-user mode.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
// macOS only: the launcher uses osascript + Finder desktop bounds and Chrome
// --app geometry flags. Fail clearly elsewhere instead of spawning a bash
// that errors out invisibly (the toast would otherwise lie "Opening…").
@@ -379,7 +424,9 @@ export function registerSystemRoutes(
// Kick off a detached update to the latest release. Returns immediately; the
// browser then polls /api/system/update/status across the service restart.
app.post('/api/system/update', async (_req, reply) => {
app.post('/api/system/update', async (req, reply) => {
// Updates and restarts the server under every user: admin-only in multi-user mode.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const result = await startUpdate();
if (result.ok) {
return { updateId: result.updateId, toTag: result.toTag, toVersion: result.toVersion };
@@ -617,13 +664,16 @@ export function registerSystemRoutes(
// synchronous-spawn precedent this endpoint is modelled on.
detached: true,
// Inherit the environment: this needs a HOME to resolve $DSH_HOME
// against, and a PATH carrying `pnpm`. ⚠️ `dsh plugin` does NOT bundle a
// against, and a PATH carrying `pnpm` (see the PATH below). ⚠️ `dsh plugin` does NOT bundle a
// package manager — it `spawnSync`s a literal `pnpm` with no npm
// fallback, so on a host without one this exits 127 and dsh's own
// stderr ("pnpm not found on PATH") is what reaches the caller through
// the OPERATION_FAILED detail below. That is the same missing
// dependency that broke the docker agent image in issue #352.
env: process.env,
// PATH leads with the directory `dsh` itself resolved to: CLI management installs
// `dsh` and `pnpm` into the user prefix (`~/.local/bin`), which a service's own PATH
// usually lacks, so `dsh plugin` could not find the `pnpm` sitting next to it.
env: { ...process.env, PATH: [dir, process.env.PATH].filter(Boolean).join(':') },
});
} catch (err) {
resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false });
@@ -718,7 +768,10 @@ export function registerSystemRoutes(
// ========== State & Lifecycle ==========
app.post('/api/cleanup-state', async () => {
app.post('/api/cleanup-state', async (req, reply) => {
// Drops every user's stale session records (and their tab-layout entries): admin-only
// in multi-user mode, like the other server-wide maintenance operations.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const activeSessionIds = new Set(ctx.sessions.keys());
const result = await ctx.tabLayouts.runStaleSessionCleanup(activeSessionIds, (ids) =>
ctx.store.cleanupSessionsByIds(ids)
@@ -866,7 +919,10 @@ export function registerSystemRoutes(
return { config: ctx.store.getConfig() };
});
app.put('/api/config', async (req) => {
app.put('/api/config', async (req, reply) => {
// Process-wide app config in state.json (poll interval, timeouts, the session cap,
// respawn defaults): admin-only in multi-user mode, like PUT /api/settings.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const configData = parseBody(ConfigUpdateSchema, req.body, 'Invalid config');
ctx.store.setConfig(configData as Partial<ReturnType<typeof ctx.store.getConfig>>);
return { config: ctx.store.getConfig() };
@@ -935,7 +991,7 @@ export function registerSystemRoutes(
// ========== Settings ==========
app.get('/api/settings', async () => {
app.get('/api/settings', async (req) => {
// A plain read. This route must NEVER write settings.json: readJsonConfig()
// answers `{}` for ANY read failure (a parse error, EACCES, EMFILE, a read
// that lands inside PUT's non-atomic write), not only for a missing file,
@@ -947,10 +1003,20 @@ export function registerSystemRoutes(
// `workspaceHooksEnabled` means ON, and the client resolves its own display
// default through planUsageChipEnabled(). Pinned by
// test/routes/system-routes-settings-get-plan-usage-default.test.ts.
return readJsonConfig(SETTINGS_PATH, 'settings', {});
const settings = await readJsonConfig<unknown>(SETTINGS_PATH, 'settings', {});
// Readable by every signed-in user, but a non-admin in multi-user mode gets it without
// the admin's credential and case names (settingsForNonAdmin). isAdmin is always true in
// single-user mode, so that answer is the file as it is.
return isAdmin(req) ? settings : settingsForNonAdmin(settings);
});
app.put('/api/settings', async (req) => {
app.put('/api/settings', async (req, reply) => {
// Multi-user mode: settings.json is ONE file for the whole server (feature switches,
// the plan-usage collection switch, run mode, respawn presets, the tunnel), so a write
// is admin-only, like every other server-wide setting (docs/multi-user-plan.md,
// Settings row). Refused FIRST: nothing is parsed, written, toggled or started for a
// non-admin. GET above stays readable by everyone. No-op in single-user mode.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record<string, unknown>;
// COD-55: enabling the Cloudflare tunnel publishes the whole app (full terminal
@@ -1089,7 +1155,10 @@ export function registerSystemRoutes(
return { success: true, data: settings.modelConfig || {} };
});
app.put('/api/execution/model-config', async (req) => {
app.put('/api/execution/model-config', async (req, reply) => {
// The `modelConfig` key of the server-wide settings.json (cron's and the plan
// orchestrator's default model): admin-only in multi-user mode, like PUT /api/settings.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const modelConfig = parseBody(ModelConfigUpdateSchema, req.body, 'Invalid model config') as Record<string, unknown>;
try {
+290
View File
@@ -0,0 +1,290 @@
/**
* @fileoverview Git worktrees of a local case (src/git-worktrees.ts holds the rules):
*
* GET /api/cases/:name/worktrees the case repository's worktrees, which of them
* Codeman manages, and the live sessions in each
* POST /api/cases/:name/worktrees create `<case>/.claude/worktrees/<name>` on a
* new branch (`{ name, branch?, base? }`)
* DELETE /api/cases/:name/worktrees/:worktree `git worktree remove` WITHOUT --force, refused
* while a live session runs inside; optional
* `?deleteBranch=true` is `git branch -d`
*
* A session starts in a worktree through the EXISTING create paths, never a new spawn path here:
* `POST /api/quick-start` with `worktree: <name>` (validated against this module's rules), or
* `POST /api/sessions` with the worktree path as `workingDir`.
*
* The case is resolved the way quick-start resolves it: an owned remote or Docker case is refused
* with a 400 (git must never run on the host over a Docker workspace, and a remote case's folder
* is on another machine); a linked case resolves for admins only in multi-user mode; anything else
* is the caller's own case space, confined with `isWorkingDirAllowed`. The case folder is probed
* BOUNDED (#516): a case on a network mount that stopped answering is a 422, never a 404.
*
* Statuses are set explicitly from the error code, so the route answers the same with or without
* the server's envelope hook.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, httpStatusForErrorCode, type ApiResponse } from '../../types.js';
import { readDockerCases } from '../../docker-hosts.js';
import { readRemoteCases } from '../../remote-hosts.js';
import { dataPath, getDataDir } from '../../config/instance.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { listClis } from '../../config/cli-registry/registry.js';
import { agentSkillDir } from '../../hooks-config.js';
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
import {
createCaseWorktree,
isPathWithin,
listCaseWorktrees,
managedWorktreesDir,
removeCaseWorktree,
resolveCaseRepository,
respellUnder,
worktreeNameError,
type CaseRepository,
type CaseWorktree,
type CreatedWorktree,
type RemovedWorktree,
type WorktreeFailure,
type WorktreeGitRunner,
} from '../../git-worktrees.js';
import { CreateWorktreeSchema, RemoveWorktreeQuerySchema } from '../schemas.js';
import {
canAccessOwned,
getAuthUser,
isAdmin,
isWorkingDirAllowed,
parseBody,
readJsonConfig,
resolveCasesDir,
validatePathWithinBase,
} from '../route-helpers.js';
import type { SessionPort } from '../ports/index.js';
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
/** Host paths of every Docker case workspace: git never runs for a case at or inside one. */
const defaultDockerWorkspaces = async (): Promise<string[]> =>
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
/**
* Every workspace skills folder a session of some registry CLI gets Codeman's agent skill in
* (`capabilities.agentSkillDir`), read from the registry at call time as data, never by CLI id.
*/
const registryAgentSkillDirs = (): string[] => {
const dirs = new Set<string>();
for (const cli of listClis()) {
const dir = agentSkillDir(cli.capabilities);
if (dir) dirs.add(dir);
}
return [...dirs];
};
/** A live session in a worktree, as the list reports it to a caller who may see it. */
export interface WorktreeSessionRef {
id: string;
name: string;
}
/** One row of `GET /api/cases/:name/worktrees`. */
export interface CaseWorktreeInfo extends CaseWorktree {
/** Live sessions running in this worktree (deepest worktree wins), all owners. */
sessionCount: number;
/** The ones the caller may see (all of them outside multi-user mode). */
sessions: WorktreeSessionRef[];
}
export interface CaseWorktreeList {
caseName: string;
repoRoot: string;
/** `<repo>/.claude/worktrees`, where Codeman creates and removes worktrees. */
worktreesDir: string;
worktrees: CaseWorktreeInfo[];
truncated: boolean;
}
type Fail = ApiResponse<never>;
function failWith(reply: FastifyReply, code: ApiErrorCode, message: string): Fail {
reply.code(httpStatusForErrorCode(code));
return createErrorResponse(code, message);
}
function failFrom(reply: FastifyReply, failure: WorktreeFailure): Fail {
return failWith(reply, ApiErrorCode[failure.code], failure.message);
}
/**
* The LOCAL folder of case `name` for this caller, or the refusal. Mirrors quick-start's
* resolution (remote, then Docker, then linked, then the caller's case space) so a name means
* the same case here as it does to the Run button.
*/
async function resolveLocalCaseFolder(
req: FastifyRequest,
name: string
): Promise<{ ok: true; casePath: string } | { ok: false; code: ApiErrorCode; message: string }> {
if (!SAFE_CASE_NAME.test(name)) {
return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Invalid case name' };
}
const user = getAuthUser(req);
const configDir = getDataDir();
// Ownership folded into the match, as quick-start does: a foreign same-named remote/Docker
// case falls through to the caller's own local case instead of shadowing it.
if ((await readRemoteCases(configDir)).some((c) => c.name === name && canAccessOwned(user, c.owner))) {
return {
ok: false,
code: ApiErrorCode.INVALID_INPUT,
message: 'Worktrees are not available for remote (SSH) cases: the repository is on another machine.',
};
}
if ((await readDockerCases(configDir)).some((c) => c.name === name && canAccessOwned(user, c.owner))) {
return {
ok: false,
code: ApiErrorCode.INVALID_INPUT,
message:
'Worktrees are not available for Docker cases: a container can write to the workspace, and git would run its hooks and filters on this machine.',
};
}
const linkedCases = await readJsonConfig<Record<string, string>>(dataPath('linked-cases.json'), 'linked cases', {});
// Linked cases carry no owner: only admins resolve them in multi-user mode.
const linked = !isMultiUserMode() || isAdmin(req) ? linkedCases[name] : undefined;
const casePath = linked || validatePathWithinBase(name, resolveCasesDir(user));
if (!casePath) return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Invalid case path' };
if (!isWorkingDirAllowed(user, casePath)) {
return { ok: false, code: ApiErrorCode.FORBIDDEN, message: 'case path is outside your workspace' };
}
const kind = await probePathKind(casePath, { pastCap: true });
if (kind === 'unknown') {
return {
ok: false,
code: ApiErrorCode.OPERATION_FAILED,
message: describeUnknownPath('Case folder', casePath, { pastCap: true }),
};
}
if (kind === 'absent') return { ok: false, code: ApiErrorCode.NOT_FOUND, message: `Case "${name}" not found` };
if (kind !== 'directory')
return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Case path is not a folder' };
return { ok: true, casePath };
}
/**
* Live local sessions, with the folder each runs in as the case's repository spells it
* (`respellUnder`: a folder named through the case's own path, which can run through a symlink,
* is moved onto the repository's real top level, where git lists the worktrees). Remote sessions
* have no local folder.
*/
function liveLocalSessions(
ctx: SessionPort,
casePath: string,
repoRoot: string
): Array<{ id: string; name: string; owner?: string; dir: string }> {
const out: Array<{ id: string; name: string; owner?: string; dir: string }> = [];
for (const session of ctx.sessions.values()) {
if (session.remote || !session.workingDir || !session.workingDir.startsWith('/')) continue;
const dir = respellUnder(session.workingDir, casePath, repoRoot);
out.push({ id: session.id, name: session.name, owner: session.owner, dir });
}
return out;
}
export function registerWorktreeRoutes(
app: FastifyInstance,
ctx: SessionPort,
deps: { git?: WorktreeGitRunner; dockerWorkspaces?: () => Promise<string[]> } = {}
): void {
const dockerWorkspaces = deps.dockerWorkspaces ?? defaultDockerWorkspaces;
/**
* Case folder → its repository, or the refusal already rendered. `casePath` is the folder as
* the case resolves it (possibly through a symlink); `repo.repoRoot` is its real path.
*/
async function caseRepository(
req: FastifyRequest,
reply: FastifyReply,
name: string
): Promise<{ ok: true; repo: CaseRepository; casePath: string; dockerRoots: string[] } | { ok: false; fail: Fail }> {
const folder = await resolveLocalCaseFolder(req, name);
if (!folder.ok) return { ok: false, fail: failWith(reply, folder.code, folder.message) };
const dockerRoots = await dockerWorkspaces();
const repo = await resolveCaseRepository(folder.casePath, { git: deps.git, dockerWorkspaces: dockerRoots });
if (!repo.ok) return { ok: false, fail: failFrom(reply, repo) };
return { ok: true, repo: repo.repo, casePath: folder.casePath, dockerRoots };
}
app.get('/api/cases/:name/worktrees', async (req, reply): Promise<ApiResponse<CaseWorktreeList>> => {
const { name } = req.params as { name: string };
const resolved = await caseRepository(req, reply, name);
if (!resolved.ok) return resolved.fail;
const { repo, casePath } = resolved;
const listed = await listCaseWorktrees(repo, { git: deps.git });
if (!listed.ok) return failFrom(reply, listed);
// Each live session belongs to the DEEPEST worktree holding its folder, so a session in a
// managed worktree is not also counted against the main checkout that contains it.
const user = getAuthUser(req);
const rows: CaseWorktreeInfo[] = listed.worktrees.map((wt) => ({ ...wt, sessionCount: 0, sessions: [] }));
for (const session of liveLocalSessions(ctx, casePath, repo.repoRoot)) {
let best: CaseWorktreeInfo | undefined;
for (const row of rows) {
if (!isPathWithin(session.dir, row.path)) continue;
if (!best || row.path.length > best.path.length) best = row;
}
if (!best) continue;
best.sessionCount += 1;
if (canAccessOwned(user, session.owner)) best.sessions.push({ id: session.id, name: session.name });
}
return {
success: true,
data: {
caseName: name,
repoRoot: repo.repoRoot,
worktreesDir: managedWorktreesDir(repo.repoRoot),
worktrees: rows,
truncated: listed.truncated,
},
};
});
app.post('/api/cases/:name/worktrees', async (req, reply): Promise<ApiResponse<{ worktree: CreatedWorktree }>> => {
const { name } = req.params as { name: string };
const body = parseBody(CreateWorktreeSchema, req.body);
const nameError = worktreeNameError(body.name);
if (nameError) return failWith(reply, ApiErrorCode.INVALID_INPUT, nameError);
const resolved = await caseRepository(req, reply, name);
if (!resolved.ok) return resolved.fail;
const created = await createCaseWorktree(resolved.repo, body, {
git: deps.git,
agentSkillDirs: registryAgentSkillDirs(),
});
if (!created.ok) return failFrom(reply, created);
reply.code(201);
return { success: true, data: { worktree: created.worktree } };
});
app.delete(
'/api/cases/:name/worktrees/:worktree',
async (req, reply): Promise<ApiResponse<{ removed: RemovedWorktree }>> => {
const { name, worktree } = req.params as { name: string; worktree: string };
const query = parseBody(RemoveWorktreeQuerySchema, req.query ?? {});
const nameError = worktreeNameError(worktree);
if (nameError) return failWith(reply, ApiErrorCode.INVALID_INPUT, nameError);
const resolved = await caseRepository(req, reply, name);
if (!resolved.ok) return resolved.fail;
const removed = await removeCaseWorktree(
resolved.repo,
{
name: worktree,
deleteBranch: query.deleteBranch === 'true' || query.deleteBranch === '1',
// Any owner's session blocks: removing the folder under a running agent is the damage
// this refusal exists to prevent, whoever started it.
isInUse: (path) =>
liveLocalSessions(ctx, resolved.casePath, resolved.repo.repoRoot).some((s) => isPathWithin(s.dir, path)),
},
// The worktree folder itself must not be (or hold) a Docker case workspace either.
{ git: deps.git, dockerWorkspaces: resolved.dockerRoots }
);
if (!removed.ok) return failFrom(reply, removed);
return { success: true, data: { removed: removed.removed } };
}
);
}
+45 -1
View File
@@ -707,7 +707,16 @@ export const StatusTelemetrySchema = z.object({
miss_recache_tokens: z.number().nullish(),
last_miss_at: z.number().nullish(),
recache_tokens_if_cold: z.number().nullish(),
last_miss_cause: z.object({ causes: z.array(z.unknown()).max(20).nullish() }).nullish(),
// Keep the first 20 causes instead of 400-ing the whole post (which also drops the
// footer, the plan chip and the model report it carries) when Claude Code reports more. The
// preprocess slices BEFORE the array validates, so an over-long array costs one bounded
// slice, never per-element validation across every entry (parsePromptCache then keeps
// the string ones among those 20).
last_miss_cause: z
.object({
causes: z.preprocess((v) => (Array.isArray(v) ? v.slice(0, 20) : v), z.array(z.unknown()).nullish()),
})
.nullish(),
})
.nullish(),
})
@@ -759,6 +768,34 @@ export const ClonePreflightSchema = z.object({
repository: z.string().min(1).max(2048),
});
// ========== Git worktrees of a case (src/git-worktrees.ts) ==========
/**
* A worktree name: one segment of letters, digits, `.`, `_`, `-`, starting with a letter or
* digit, at most 64 characters (Claude Code's own `--worktree` limit). The finer rules (no `..`,
* no trailing `.lock`) are `worktreeNameError()`, which every route applies after this.
*/
const worktreeNameSchema = z
.string()
.min(1)
.max(64)
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, 'Invalid worktree name. Use letters, digits, ".", "_" and "-".');
/** Schema for POST /api/cases/:name/worktrees: a new worktree on a new branch. */
export const CreateWorktreeSchema = z.object({
name: worktreeNameSchema,
/** New branch name; default `worktree-<name>`, Claude Code's convention. */
branch: z.string().min(1).max(200).optional(),
/** Start point (branch, tag or commit); default the case's current HEAD. */
base: z.string().min(1).max(200).optional(),
});
/** Query of DELETE /api/cases/:name/worktrees/:worktree. */
export const RemoveWorktreeQuerySchema = z.object({
/** Also `git branch -d` the worktree's branch (never `-D`). */
deleteBranch: z.enum(['true', 'false', '1', '0']).optional(),
});
const RemoteCommandOverridesSchema = z
.object({
shell: z.string().min(1).max(300).optional(),
@@ -1139,6 +1176,13 @@ export const QuickStartSchema = z.object({
parentSessionId: parentSessionIdSchema,
/** Tab group to place the new session in: see tabGroupIdSchema. */
tabGroupId: tabGroupIdSchema,
/**
* Start the session in this git worktree of the case (`<case>/.claude/worktrees/<worktree>`,
* created through POST /api/cases/:name/worktrees) instead of the case folder. Local cases
* only: refused (400) for remote and Docker cases. The worktree must be a registered worktree
* of the case's repository; it is never created here.
*/
worktree: worktreeNameSchema.optional(),
/** Model override written to <case>/.claude/settings.local.json (e.g. "opus[1m]").
* Empty string clears. Applied for local AND docker cases (the docker workspace is
* a real host dir, so the settings file crosses the bind mount); rejected for
+135
View File
@@ -0,0 +1,135 @@
/**
* @fileoverview Screen-detected blocked dialogs: what the server does with them.
*
* A CLI that has no hook to say "I stopped to ask you something" (codex: `hooks: 'none'`,
* and the one CLI Codeman launches with its approvals on by default) declares
* `capabilities.workDetect.blockedLine`, and `Session` reads its dialogs off the pane and
* emits `blockedChanged` (see `_noteBlockedReading()` in session.ts). This module turns
* that edge into exactly what a Claude `permission_prompt` hook produces in
* `hook-event-routes.ts`, so every surface that already knows that event (the red tab
* alert and desktop notification, web push, webhooks, the phone overview's NEEDS YOU, the
* Approvals Inbox, `codeman tui`, the run summary and the `blocked` wait signal) needs no
* second code path:
*
* - `raiseScreenBlocked()`: the `blocked` wait signal, an Approvals Inbox item, the
* `hook:permission_prompt` broadcast and the push/webhook.
* - `clearScreenBlocked()`: resolve that item, which broadcasts `approval:resolved`, and
* every client clears the alert from it (`_onApprovalResolved`, approvals-ui.js).
*
* Rules it keeps:
* - ⚠️ ALERT ONLY. The item is `answerInTerminal` (the #610 shape the DeepSeek Harness
* items use): the answer route refuses it, push carries no Approve/Deny, and the inbox
* card and the overview strip say "answer in the terminal". The dialog is read
* off a screen the agent writes most of, so a forged match must cost a false alert and
* never a keystroke. Codeman does not answer these dialogs.
* - The item gets no `capture` callback, only the frame it was raised from as context:
* the inbox's option parser and staleness sweep are shaped for Claude's dialogs, and the
* SESSION owns this item's lifetime (it re-reads the screen while the dialog is up).
* - Hooks win. A session whose CLI reports blocked itself (`hooksAvailableForMode()`)
* gets nothing here, so a CLI that declared both could never alert twice for one dialog.
* - Only the item this module opened is resolved on clear, never a hook-raised one.
*
* @dependencies approval-inbox, session-wait-registry, sse-events, cli-registry
* @consumedby web/session-listener-wiring (blockedChanged)
* @module web/screen-blocked
*/
import { getCli } from '../config/cli-registry/registry.js';
import type { SessionMode } from '../types.js';
import { approvalInbox } from './approval-inbox.js';
import { sessionWaits, hooksAvailableForMode, sessionHookOptions } from './session-wait-registry.js';
import { SseEvent } from './sse-events.js';
/** Marks a screen-read event, so a consumer can tell it from a hook-raised one. */
export const SCREEN_BLOCKED_SOURCE = 'screen';
/** The facts about a session this module reads (structural, so tests need no `Session`). */
export interface ScreenBlockedSession {
id: string;
name: string;
mode: SessionMode;
deepSeekStatusReporting?: boolean;
docker?: unknown;
remote?: unknown;
}
/** What the server lends it: the same channels the hook route uses. */
export interface ScreenBlockedDeps {
broadcast(event: string, data: unknown): void;
broadcastSessionStateDebounced(sessionId: string): void;
sendPushNotifications(event: string, data: Record<string, unknown>): void;
getRunSummaryTracker?(
sessionId: string
): { recordHookEvent(event: string, data?: Record<string, unknown>): void } | undefined;
}
/**
* The line every surface shows for such a dialog. Built from the CLI's own label (data,
* never an id branch), and from nothing on the screen: the dialog's wording is the
* agent's to choose (a question's text, a command preview), so none of it reaches a push
* or a webhook. The approval card shows the frame itself, escaped.
*/
export function screenBlockedMessage(mode: SessionMode): string {
return `${getCli(mode)?.label ?? mode} is waiting for your answer in the terminal`;
}
/**
* The pane now shows a dialog its CLI is blocked on: raise everything a Claude
* `permission_prompt` hook would.
*
* @param frame the capture the dialog was read from, kept as the card's context
* @returns the id of the Approvals Inbox item opened, or null when nothing was raised
*/
export function raiseScreenBlocked(
session: ScreenBlockedSession,
deps: ScreenBlockedDeps,
frame?: string | null
): string | null {
if (hooksAvailableForMode(session.mode, sessionHookOptions(session))) return null;
const message = screenBlockedMessage(session.mode);
// Before anything that can throw, like the hook route: a caller parked on `blocked`
// is the reason this exists for agents.
sessionWaits.notifySignal(session.id, 'blocked');
const item = approvalInbox.notePrompt({
sessionId: session.id,
sessionName: session.name,
kind: 'permission',
message,
answerInTerminal: true,
context: frame ?? undefined,
});
const data = { source: SCREEN_BLOCKED_SOURCE, message };
deps.broadcast(SseEvent.HookPermissionPrompt, {
sessionId: session.id,
timestamp: Date.now(),
...data,
approvalId: item.id,
});
// Same ride-along as the hook route: the home screens rank the blocked group on the
// session's stamps, so the row moves without waiting for some unrelated event.
deps.broadcastSessionStateDebounced(session.id);
// No approvalId: push would offer Approve/Deny for a dialog nobody here may answer.
deps.sendPushNotifications(SseEvent.HookPermissionPrompt, {
sessionId: session.id,
sessionName: session.name,
...data,
});
deps.getRunSummaryTracker?.(session.id)?.recordHookEvent('permission_prompt', data);
return item.id;
}
/**
* The dialog left the screen (answered, cancelled, or the pane went away): resolve the
* item `raiseScreenBlocked()` opened, which clears the alert on every client. An item
* that is no longer that one (dismissed, expired, or superseded by a hook) is left alone.
*
* @param itemId what `raiseScreenBlocked()` returned
*/
export function clearScreenBlocked(sessionId: string, itemId: string | null): void {
if (!itemId) return;
if (approvalInbox.getForSession(sessionId)?.id !== itemId) return;
approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal');
}
+11
View File
@@ -112,6 +112,7 @@ import { sessionWaits } from './session-wait-registry.js';
import { intentStore } from '../intent-store.js';
import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
import { approvalInbox } from './approval-inbox.js';
import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
import {
wireRespawnListeners,
@@ -191,6 +192,7 @@ import {
registerStatusTelemetryRoutes,
registerSystemRoutes,
registerCaseRoutes,
registerWorktreeRoutes,
registerSessionRoutes,
registerRespawnRoutes,
registerRalphRoutes,
@@ -1153,6 +1155,7 @@ export class WebServer extends EventEmitter {
registerStatusTelemetryRoutes(this.app, ctx);
registerSystemRoutes(this.app, ctx);
registerCaseRoutes(this.app, ctx);
registerWorktreeRoutes(this.app, ctx);
// The registry's lifetime is the server's: it drops per-session wake state on every
// cleanup path and resolves in-flight wakes on shutdown.
this.remoteWake = registerSessionRoutes(this.app, ctx);
@@ -2835,6 +2838,14 @@ export class WebServer extends EventEmitter {
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
body += body ? ' ' : '';
body += `Tool: ${String(data.tool_name)}`;
} else if (event === SseEvent.HookPermissionPrompt && data.source === SCREEN_BLOCKED_SOURCE && data.message) {
// A dialog read off a hook-less CLI's screen (web/screen-blocked.ts), whose message
// is built from the CLI's label, never from the screen. Keyed on the source so a
// Claude hook's own `message` keeps the body it always had; capped like the error
// text, since a hook poster holding the secret can claim the source too (as it can
// already claim any `tool_name` above).
body += body ? ' ' : '';
body += String(data.message).slice(0, 200);
}
return body;
}
+21
View File
@@ -29,6 +29,7 @@ import { getLifecycleLog } from '../session-lifecycle-log.js';
import { fileStreamManager } from '../file-stream-manager.js';
import { sessionWaits } from './session-wait-registry.js';
import { approvalInbox } from './approval-inbox.js';
import { raiseScreenBlocked, clearScreenBlocked } from './screen-blocked.js';
import { composeAutoSessionName, deriveAutoSessionName } from '../session-auto-name.js';
import { MAX_SESSION_NAME_LENGTH } from '../config/terminal-limits.js';
@@ -44,6 +45,7 @@ export interface SessionListenerRefs {
working: () => void;
idle: () => void;
watchingChanged: () => void;
blockedChanged: (blocked: boolean, frame: string | null) => void;
displayModelChanged: () => void;
taskCreated: (task: BackgroundTask) => void;
taskUpdated: (task: BackgroundTask) => void;
@@ -98,6 +100,8 @@ interface SessionListenerDeps {
* Call `attachSessionListeners()` after to wire them to the session.
*/
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
/** The Approvals Inbox item the session's last screen-read dialog opened (see blockedChanged). */
let screenBlockedItemId: string | null = null;
return {
// ─── Terminal Output ─────────────────────────────────────
@@ -276,6 +280,21 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
deps.broadcastSessionStateDebounced(session.id);
},
/**
* The pane started or stopped showing a dialog its CLI is blocked on, read off the
* screen for a CLI with no hook to report it (`capabilities.workDetect.blockedLine`:
* codex). Raised as a Claude `permission_prompt` hook would be, alert-only, and
* cleared by resolving the item it opened; web/screen-blocked.ts has the rules.
*/
blockedChanged: (blocked: boolean, frame: string | null) => {
if (blocked) {
screenBlockedItemId = raiseScreenBlocked(session, deps, frame);
} else {
clearScreenBlocked(session.id, screenBlockedItemId);
screenBlockedItemId = null;
}
},
/**
* Pushes the session state when the model the CLI reports changes (its statusline,
* or its own footer), which the status does not reflect. Persisted too: a restart
@@ -519,6 +538,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
session.on('working', refs.working);
session.on('idle', refs.idle);
session.on('watchingChanged', refs.watchingChanged);
session.on('blockedChanged', refs.blockedChanged);
session.on('displayModelChanged', refs.displayModelChanged);
session.on('taskCreated', refs.taskCreated);
session.on('taskUpdated', refs.taskUpdated);
@@ -557,6 +577,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
session.off('working', refs.working);
session.off('idle', refs.idle);
session.off('watchingChanged', refs.watchingChanged);
session.off('blockedChanged', refs.blockedChanged);
session.off('displayModelChanged', refs.displayModelChanged);
session.off('taskCreated', refs.taskCreated);
session.off('taskUpdated', refs.taskUpdated);
+35 -14
View File
@@ -28,11 +28,17 @@
* `idle` / `working` / `exit` come from `Session`'s own events (heuristic: output
* stabilization plus prompt detection), so `idle` can flap mid-turn when a spinner
* pauses. `stop` and `blocked` come from Claude Code hooks (`POST /api/hook-event`)
* and are definitive. Only `claude` mode installs those hooks, so `stop` and
* `blocked` never fire for anything else: callers must be rejected up front rather
* than left to hit the timeout. `hooksAvailableForMode()` keeps that rule in one
* place, and it is keyed on the MODE rather than on `isExternalCliMode()` because
* `shell` is not an external CLI yet installs no hooks either.
* and are definitive. Only `claude` mode installs those hooks (and `deepseek`'s status
* bridge reports the same two), so `stop` never fires for anything else: callers must
* be rejected up front rather than left to hit the timeout. `hooksAvailableForMode()`
* keeps that rule in one place, and it is keyed on the MODE rather than on
* `isExternalCliMode()` because `shell` is not an external CLI yet installs no hooks
* either.
*
* `blocked` has a second source: a CLI with no hook for it can declare the dialogs it
* blocks on (`capabilities.workDetect.blockedLine`, codex), and `Session` reads them off
* the screen (`web/screen-blocked.ts`). `blockedSignalAvailable()` is the rule for that
* signal. `stop` has no such source, because nothing on a screen says a turn is over.
*
* ## Ordering contract for the wiring
*
@@ -152,7 +158,9 @@ export function parseWaitSignals(raw: unknown): ParsedWaitSignals {
* `stopped` and `error` both map to `exit` because both mean "not running", so a
* caller that does catch one of them does not block. `blocked` is deliberately
* underivable here, it exists only as a hook event until it becomes a real
* `SessionStatus` (see the deferred Part 3 in the plan).
* `SessionStatus` (see the deferred Part 3 in the plan). A screen-read dialog IS a
* level a later caller can find (`Session.screenBlocked`), and the wait route answers
* that one itself.
*/
export function signalForStatus(status: SessionStatus): WaitSignal | null {
switch (status) {
@@ -168,9 +176,6 @@ export function signalForStatus(status: SessionStatus): WaitSignal | null {
}
}
/** Signals that arrive only via Claude Code hooks, so only `claude` mode can emit them. */
const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked'];
/** Per-session facts that can turn a mode's hook capability OFF for one session. */
export interface HookCapabilityOptions {
/**
@@ -266,6 +271,19 @@ export function sessionHookOptions(session: {
};
}
/**
* Whether `blocked` can ever fire for this session: its CLI reports it through hooks
* (`hooksAvailableForMode()`), or Codeman reads the dialogs it blocks on off its screen
* (`capabilities.workDetect.blockedLine`, codex). The screen half needs no per-session
* facts: a remote or Docker pane is still a local tmux pane showing the CLI's screen
* (their nested tmux runs with `status off`, so nothing is drawn below the CLI).
*
* ⚠️ Says nothing about `stop`. A screen shows a dialog; it never says a turn ended.
*/
export function blockedSignalAvailable(mode: SessionMode, options: HookCapabilityOptions = {}): boolean {
return hooksAvailableForMode(mode, options) || !!getCli(mode)?.capabilities.workDetect?.blockedLine;
}
/** Outcome of resolving a caller-supplied wait target against a session's mode. */
export interface ResolvedWaitSignals {
/** Signals to actually wait on. Empty when `error` is set. */
@@ -279,9 +297,9 @@ export interface ResolvedWaitSignals {
* every wait endpoint shares:
*
* 1. An unknown token is an ERROR, never a silent fallback to the default.
* 2. Hook-only signals are rejected when asked for EXPLICITLY on a mode that emits
* no hooks, but merely dropped from the DEFAULT set: omitting the parameter must
* never 400.
* 2. A signal the session can never emit (`stop` without hooks, `blocked` without
* hooks or a screen-read `blockedLine`) is rejected when asked for EXPLICITLY, but
* merely dropped from the DEFAULT set: omitting the parameter must never 400.
*
* Shared by `GET .../wait` and the `wait` field on `POST .../input` so the two can
* not drift; the second-guessing that produces is worse than the duplication.
@@ -304,7 +322,9 @@ export function resolveWaitSignals(
};
}
const unsupported = new Set<WaitSignal>(hooksAvailableForMode(options.mode, options) ? [] : HOOK_ONLY_SIGNALS);
const unsupported = new Set<WaitSignal>();
if (!hooksAvailableForMode(options.mode, options)) unsupported.add('stop');
if (!blockedSignalAvailable(options.mode, options)) unsupported.add('blocked');
if (parsed.signals.length === 0) {
return { until: DEFAULT_WAIT_SIGNALS.filter((signal) => !unsupported.has(signal)), error: null };
@@ -324,7 +344,8 @@ export function resolveWaitSignals(
`a remote host, where the local status bridge cannot reach the harness. Use idle or exit.`
: `Signal(s) ${rejected.join(', ')} never fire for this deepseek session: its status bridge is off ` +
`(deepSeekConfig.statusReporting: false), so nothing posts hook events. Use idle or exit.`
: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). Use idle or exit.`,
: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). ` +
`Use ${unsupported.has('blocked') ? 'idle or exit' : 'idle, blocked or exit'}.`,
};
}
return { until: parsed.signals, error: null };
+7 -2
View File
@@ -26,7 +26,8 @@
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
* - **Image / attachments** (2): image:detected, attachment:detected
* - **Hooks** (10): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, agent_working, teammate_idle, task_completed, prompt_submitted
* (agent_working is the odd one out: reported by the DeepSeek Harness status bridge, not by a Claude Code hook)
* (agent_working is the odd one out: reported by the DeepSeek Harness status bridge, not by a Claude Code hook;
* permission_prompt also arrives with `source: 'screen'` for a codex dialog read off its screen)
* - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox)
* - **Custom Model Endpoint Profiles** (1): swapped-out (a session's model got evicted by another session on the same llama-swap endpoint)
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
@@ -365,7 +366,11 @@ export const AttachmentDetected = 'attachment:detected' as const;
/** Claude Code hook: session idle, waiting for input. */
export const HookIdlePrompt = 'hook:idle_prompt' as const;
/** Claude Code hook: tool requesting permission. */
/**
* Claude Code hook: tool requesting permission. Also raised by the server itself, with
* `source: 'screen'`, for a dialog read off a hook-less CLI's screen (codex; see
* `web/screen-blocked.ts`), which is cleared by `approval:resolved`.
*/
export const HookPermissionPrompt = 'hook:permission_prompt' as const;
/** Claude Code hook: elicitation dialog (Claude asking a question). */
export const HookElicitationDialog = 'hook:elicitation_dialog' as const;