Merge remote-tracking branch 'origin/master' into dev

This commit is contained in:
Teigen
2026-04-04 08:07:15 +08:00
96 changed files with 459 additions and 989 deletions
+11
View File
@@ -274,6 +274,7 @@ export class Session extends EventEmitter {
private _lastPromptTime: number = 0;
private activityTimeout: NodeJS.Timeout | null = null;
private _awaitingIdleConfirmation: boolean = false; // Prevents timeout reset during idle detection
private _trustDialogAccepted: boolean = false; // Prevents repeated trust dialog auto-accept
private _taskTracker: TaskTracker;
// Token tracking for auto-clear
@@ -1118,6 +1119,16 @@ export class Session extends EventEmitter {
this._handleTerminalOutput(data);
// === Auto-accept workspace trust dialog ===
// Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory.
// Codeman sessions always use --dangerously-skip-permissions, so auto-accept.
if (!this._trustDialogAccepted && data.includes('trust this folder')) {
this._trustDialogAccepted = true;
console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`);
// Send Enter to accept the default selection ("Yes, I trust this folder")
this.writeViaMux('\r');
}
// === Idle/working detection runs on every chunk (latency-sensitive) ===
// Detect if Claude is working or at prompt
// The prompt line contains "❯" when waiting for input
+2 -2
View File
@@ -185,8 +185,8 @@ function buildSpawnCommand(options: {
}): string {
if (options.mode === 'claude') {
// Validate model to prevent command injection
const safeModel = options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined;
const modelFlag = safeModel ? ` --model ${safeModel}` : '';
const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined;
const modelFlag = safeModel ? ` --model "${safeModel}"` : '';
// Use --resume to restore a previous conversation, otherwise --session-id for new sessions.
// Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.),
// fall back to a new session with --session-id so the pane doesn't die.
+6 -2
View File
@@ -523,6 +523,9 @@ class CodemanApp {
SwipeHandler.init();
VoiceInput.init();
KeyboardAccessoryBar.init();
// Apply keyboard bar mode from settings
const _kbSettings = this.loadAppSettingsFromStorage();
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
this.applyHeaderVisibilitySettings();
this.applyTabWrapSettings();
this.applyMonitorVisibility();
@@ -2105,8 +2108,9 @@ class CodemanApp {
// Track working directory for path normalization in Project Insights
this.currentSessionWorkingDir = session?.workingDir || null;
if (session && session.pid === null && session.status === 'idle') {
// This is a restored session - attach to the existing screen/shell
if (session && session.pid === null && !session._ended) {
// Session has no PTY attached — either restored after server restart
// or detached for some other reason. Re-attach regardless of status.
try {
const endpoint = session.mode === 'shell'
? `/api/sessions/${sessionId}/shell`
+12 -1
View File
@@ -892,6 +892,17 @@
</label>
</div>
<div class="settings-item settings-item-multiline" title="Show additional buttons (Tab, Shift+Tab, Ctrl+O, Esc, Alt+Enter, left/right arrows) in the mobile keyboard accessory bar.">
<div class="settings-item-text">
<span class="settings-item-label">Extended Keyboard Bar</span>
<span class="settings-item-desc">Extra keys: Tab, Esc, arrows, Ctrl+O</span>
</div>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsExtendedKeyboardBar">
<span class="slider"></span>
</label>
</div>
<!-- Header Displays Section -->
<div class="settings-section-header">Header Displays</div>
<div class="settings-item" title="Show A-/A+ font size buttons in header">
@@ -1393,7 +1404,7 @@
<div class="case-manage-list" id="caseManageList">
<!-- Populated by JS -->
</div>
<span class="form-hint" style="margin-top: 8px; display: block;">Drag or use arrows to reorder. Changes are saved automatically.</span>
<span class="form-hint" style="margin-top: 8px; display: block;">Use arrows to reorder. Changes are saved automatically.</span>
</div>
</div>
<div class="form-actions">
+53 -14
View File
@@ -4,7 +4,7 @@
* Defines two exports:
*
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
* keyboard on mobile: Esc, arrow up/down, Tab, Shift+Tab, Ctrl+O, /init, /clear, /compact, paste, and dismiss.
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, and dismiss.
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
* Commands are sent as text + Enter separately for Ink compatibility.
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
@@ -33,16 +33,37 @@
*/
const KeyboardAccessoryBar = {
element: null,
_mode: 'simple', // 'simple' or 'extended'
/** Create and inject the accessory bar */
init() {
// Only on mobile
if (!MobileDetection.isTouchDevice()) return;
/** HTML for simple mode: arrows, commands, paste, dismiss */
_simpleButtons: `
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M5 15l7-7 7 7"/>
</svg>
</button>
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-down" title="Arrow down">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M19 9l-7 7-7-7"/>
</svg>
</button>
<button class="accessory-btn" data-action="init" title="/init">/init</button>
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
</svg>
</button>
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M19 9l-7 7-7-7"/>
</svg>
</button>`,
// Create accessory bar element
this.element = document.createElement('div');
this.element.className = 'keyboard-accessory-bar';
this.element.innerHTML = `
/** HTML for extended mode: all keys including arrows, Tab, Esc, etc. */
_extendedButtons: `
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M5 15l7-7 7 7"/>
@@ -77,12 +98,21 @@ const KeyboardAccessoryBar = {
<button class="accessory-btn" data-action="init" title="/init">/init</button>
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
<button class="accessory-btn accessory-btn-arrow" data-action="dismiss" title="Dismiss keyboard">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M19 9l-7 7-7-7"/>
</svg>
</button>
`;
</button>`,
/** Create and inject the accessory bar */
init() {
// Only on mobile
if (!MobileDetection.isTouchDevice()) return;
// Create accessory bar element
this.element = document.createElement('div');
this.element.className = 'keyboard-accessory-bar';
this.element.innerHTML = this._simpleButtons;
// Add click handlers — preventDefault stops event from reaching terminal
this.element.addEventListener('click', (e) => {
@@ -95,7 +125,8 @@ const KeyboardAccessoryBar = {
this.handleAction(action, btn);
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
if ((action === 'scroll-up' || action === 'scroll-down' || action === 'arrow-left' || action === 'arrow-right' || action === 'tab' || action === 'shift-tab' || action === 'ctrl-o' || action === 'opt-enter' || action === 'esc') ||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc']);
if (refocusActions.has(action) ||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
if (typeof app !== 'undefined' && app.terminal) {
app.terminal.focus();
@@ -110,6 +141,14 @@ const KeyboardAccessoryBar = {
}
},
/** Switch between 'simple' and 'extended' button layouts */
setMode(mode) {
if (mode === this._mode || !this.element) return;
this._mode = mode;
this.clearConfirm();
this.element.innerHTML = mode === 'extended' ? this._extendedButtons : this._simpleButtons;
},
_confirmTimer: null,
_confirmAction: null,
+51 -12
View File
@@ -943,13 +943,33 @@ html.mobile-init .file-browser-panel {
.accessory-btn-arrow {
padding: 6px 10px;
background: #1e3a5f;
border-color: rgba(59, 130, 246, 0.3);
color: #93c5fd;
background: #2563eb;
border-color: rgba(59, 130, 246, 0.5);
color: #fff;
}
.accessory-btn-arrow:active {
background: #1d4ed8;
}
.accessory-btn-dismiss {
margin-left: auto;
flex: 1 1 0;
max-width: 100px;
padding: 10px 8px;
background: #2563eb;
border-color: rgba(59, 130, 246, 0.5);
color: #fff;
font-weight: 600;
}
.accessory-btn-dismiss svg {
width: 22px;
height: 22px;
}
.accessory-btn-dismiss:active {
background: #1d4ed8;
}
/* Voice preview — positioned above accessory bar on mobile */
@@ -981,9 +1001,9 @@ html.mobile-init .file-browser-panel {
background: rgba(0, 0, 0, 0.6);
z-index: 10000;
display: flex;
align-items: flex-end;
align-items: flex-start;
justify-content: center;
padding-bottom: env(safe-area-inset-bottom, 12px);
padding-top: 15vh;
}
.paste-dialog {
@@ -993,13 +1013,12 @@ html.mobile-init .file-browser-panel {
padding: 12px;
width: calc(100% - 24px);
max-width: 400px;
margin-bottom: 8px;
}
.paste-textarea {
width: 100%;
min-height: 60px;
max-height: 120px;
min-height: 80px;
max-height: 200px;
background: var(--bg-primary, #0d0d14);
color: var(--text-primary, #e0e0e0);
border: 1px solid var(--border-color, #444);
@@ -2110,13 +2129,33 @@ html.mobile-init .file-browser-panel {
.accessory-btn-arrow {
padding: 6px 10px;
background: #1e3a5f;
border-color: rgba(59, 130, 246, 0.3);
color: #93c5fd;
background: #2563eb;
border-color: rgba(59, 130, 246, 0.5);
color: #fff;
}
.accessory-btn-arrow:active {
background: #2563eb;
background: #1d4ed8;
}
.accessory-btn-dismiss {
margin-left: auto;
flex: 1 1 0;
max-width: 80px;
padding: 10px 8px;
background: #334d6e;
border-color: rgba(100, 150, 200, 0.4);
color: #c0d4e8;
font-weight: 600;
}
.accessory-btn-dismiss svg {
width: 20px;
height: 20px;
}
.accessory-btn-dismiss:active {
background: #3d5f85;
}
/* ============================================================================
+7 -2
View File
@@ -319,6 +319,7 @@ Object.assign(CodemanApp.prototype, {
this.loadTunnelStatus();
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? false;
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
// Claude CLI settings
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
@@ -1125,6 +1126,7 @@ Object.assign(CodemanApp.prototype, {
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
// Claude CLI settings
claudeMode: document.getElementById('appSettingsClaudeMode').value,
@@ -1245,9 +1247,12 @@ Object.assign(CodemanApp.prototype, {
// Apply CJK input visibility immediately
this._updateCjkInputState();
// Apply keyboard bar mode
KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple');
// Save to server (includes notification prefs for cross-browser persistence)
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, ...serverSettings } = settings;
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, ...serverSettings } = settings;
try {
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
@@ -1631,7 +1636,7 @@ Object.assign(CodemanApp.prototype, {
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
]);
// Merge settings: non-display keys always sync from server,
// display keys only seed from server when localStorage has no value
+3 -2
View File
@@ -19,6 +19,7 @@ import { SseEvent } from '../sse-events.js';
import type { EventPort, ConfigPort } from '../ports/index.js';
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
async function readLinkedCases(): Promise<Record<string, string>> {
@@ -46,7 +47,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
try {
const entries = await fs.readdir(CASES_DIR, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory()) {
if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) {
cases.push({
name: e.name,
path: join(CASES_DIR, e.name),
@@ -62,7 +63,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const linkedCases = await readLinkedCases();
const existingNames = new Set(cases.map((c) => c.name));
for (const [name, path] of Object.entries(linkedCases)) {
if (!existingNames.has(name) && existsSync(path)) {
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
cases.push({
name,
path,
+23 -9
View File
@@ -544,7 +544,8 @@ export class WebServer extends EventEmitter {
cacheControl: false,
preCompressed: true,
setHeaders: (res, path) => {
if (path.endsWith('.html')) {
// Use .includes() not .endsWith() — preCompressed serves .html.br/.html.gz
if (path.includes('.html')) {
res.setHeader('Cache-Control', 'no-cache');
} else {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
@@ -1766,15 +1767,28 @@ export class WebServer extends EventEmitter {
this.sessions.set(session.id, session);
await this.setupSessionListeners(session);
this.persistSessionState(session);
// Mark it as restored (not started yet - user needs to attach)
getLifecycleLog().log({
event: 'recovered',
sessionId: session.id,
name: session.name,
});
console.log(`[Server] Restored session ${session.id} from mux ${muxSession.muxName}`);
// Auto-attach PTY to the surviving tmux session immediately.
// This ensures ALL sessions resume capturing output right away,
// not just the one the client happens to select first.
try {
await session.startInteractive();
getLifecycleLog().log({
event: 'recovered',
sessionId: session.id,
name: session.name,
});
console.log(`[Server] Restored and attached session ${session.id} from mux ${muxSession.muxName}`);
} catch (attachErr) {
console.error(`[Server] Failed to attach session ${session.id}, keeping as detached:`, attachErr);
getLifecycleLog().log({
event: 'recovered',
sessionId: session.id,
name: session.name,
});
}
this.persistSessionState(session);
}
}