+
+
+
+
Session
+
会话加载中
+
正在整理消息与上下文…
+
+
+
+
+
+
diff --git a/public/style.css b/public/style.css
index 0b728cf..e618cf8 100644
--- a/public/style.css
+++ b/public/style.css
@@ -1,3 +1,5 @@
+@import url('https://fonts.googleapis.com/css2?family=Chivo+Mono:wght@400;700;800&display=swap');
+
/* ============================================
CC-Web — 和風暖色調 (Japanese Warm Theme)
============================================ */
@@ -25,6 +27,306 @@
--header-height: 52px;
--input-max-height: 200px;
--safe-bottom: env(safe-area-inset-bottom, 0px);
+ --font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Hiragino Sans', 'Noto Sans CJK SC', Roboto, sans-serif;
+ --page-background: #faf6f0;
+ --login-background: linear-gradient(135deg, #faf6f0 0%, #f0e8dc 50%, #e8dccf 100%);
+ --surface-strong: #fff;
+ --shadow-strong: 0 8px 32px rgba(45, 31, 20, 0.08);
+ --theme-card-bg: rgba(255, 249, 242, 0.72);
+ --theme-card-border: rgba(221, 208, 192, 0.92);
+ --loading-overlay-layer-a: rgba(250, 246, 240, 0.76);
+ --loading-overlay-layer-b: rgba(233, 224, 212, 0.82);
+ --loading-overlay-scrim: rgba(45, 31, 20, 0.18);
+ --loading-card-bg: linear-gradient(180deg, rgba(255, 250, 245, 0.98), rgba(249, 242, 233, 0.95));
+ --loading-card-border: rgba(192, 85, 58, 0.16);
+ --loading-card-shadow: 0 24px 60px rgba(45, 31, 20, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.75);
+ --loading-badge-bg: rgba(245, 221, 212, 0.92);
+ --loading-badge-text: var(--accent);
+ --loading-bar-bg: rgba(221, 208, 192, 0.85);
+ --loading-bar-fill: linear-gradient(90deg, #c0553a, #d98666, #c0553a);
+}
+
+html[data-theme='coolvibe'] {
+ --bg-primary: #eef7f9;
+ --bg-secondary: #f7fbfc;
+ --bg-tertiary: #dfeef2;
+ --bg-bubble-user: #0891b2;
+ --bg-bubble-assistant: #ffffff;
+ --text-primary: #0d1b1f;
+ --text-secondary: #46606a;
+ --text-muted: #6f8790;
+ --border-color: #cfe0e6;
+ --accent: #0891b2;
+ --accent-hover: #0b7289;
+ --accent-light: rgba(8, 145, 178, 0.12);
+ --success: #2e8a61;
+ --danger: #d65567;
+ --info: #1976a4;
+ --scrollbar-thumb: #a7c4ce;
+ --font-ui: 'Chivo Mono', ui-monospace, monospace;
+ --page-background:
+ radial-gradient(circle at top, rgba(8, 145, 178, 0.12), transparent 34%),
+ linear-gradient(180deg, #f6fbfc, #eef7f9 46%, #e8f3f6);
+ --login-background:
+ radial-gradient(circle at center, rgba(8, 145, 178, 0.1), transparent 40%),
+ linear-gradient(180deg, #fbfeff, #eef7f9);
+ --surface-strong: #ffffff;
+ --shadow-strong: 0 18px 40px rgba(9, 54, 69, 0.12);
+ --theme-card-bg: rgba(255, 255, 255, 0.88);
+ --theme-card-border: rgba(207, 224, 230, 0.96);
+ --loading-overlay-layer-a: rgba(241, 251, 253, 0.78);
+ --loading-overlay-layer-b: rgba(219, 239, 244, 0.84);
+ --loading-overlay-scrim: rgba(10, 52, 67, 0.16);
+ --loading-card-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(240, 249, 251, 0.96));
+ --loading-card-border: rgba(8, 145, 178, 0.18);
+ --loading-card-shadow: 0 24px 60px rgba(10, 52, 67, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.84);
+ --loading-badge-bg: rgba(8, 145, 178, 0.12);
+ --loading-badge-text: #0a6d83;
+ --loading-bar-bg: rgba(191, 220, 228, 0.92);
+ --loading-bar-fill: linear-gradient(90deg, #0891b2, #34c4de, #0891b2);
+}
+
+html[data-theme='coolvibe'] body {
+ letter-spacing: -0.01em;
+}
+
+html[data-theme='coolvibe'] .login-box,
+html[data-theme='coolvibe'] .settings-panel,
+html[data-theme='coolvibe'] .modal-panel,
+html[data-theme='coolvibe'] .option-picker,
+html[data-theme='coolvibe'] .chat-agent-menu {
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(244, 250, 252, 0.96));
+ border-color: rgba(191, 220, 228, 0.96);
+ box-shadow:
+ 0 18px 44px rgba(11, 73, 92, 0.12),
+ inset 0 1px 0 rgba(255, 255, 255, 0.9);
+}
+
+html[data-theme='coolvibe'] .sidebar {
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(242, 249, 251, 0.98));
+ border-right-color: rgba(191, 220, 228, 0.98);
+ box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.8);
+}
+
+html[data-theme='coolvibe'] .sidebar-header,
+html[data-theme='coolvibe'] .sidebar-footer {
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(244, 250, 252, 0.8));
+ border-color: rgba(191, 220, 228, 0.96);
+}
+
+html[data-theme='coolvibe'] .brand {
+ color: #5f7f87;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
+
+html[data-theme='coolvibe'] .new-chat-btn,
+html[data-theme='coolvibe'] .send-btn {
+ background:
+ linear-gradient(135deg, #0a8fb0, #11b8d7);
+ color: #f7feff;
+ border: 1px solid rgba(8, 145, 178, 0.1);
+ box-shadow:
+ 0 10px 22px rgba(8, 145, 178, 0.22),
+ inset 0 1px 0 rgba(255, 255, 255, 0.34);
+}
+
+html[data-theme='coolvibe'] .new-chat-btn:hover,
+html[data-theme='coolvibe'] .send-btn:hover {
+ background:
+ linear-gradient(135deg, #0b7f9b, #0fa6c3);
+}
+
+html[data-theme='coolvibe'] .attach-btn,
+html[data-theme='coolvibe'] .abort-btn,
+html[data-theme='coolvibe'] .settings-btn,
+html[data-theme='coolvibe'] .new-chat-arrow {
+ background: rgba(8, 145, 178, 0.08);
+ color: #0c6980;
+ border: 1px solid rgba(167, 205, 216, 0.84);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
+}
+
+html[data-theme='coolvibe'] .attach-btn:hover,
+html[data-theme='coolvibe'] .abort-btn:hover,
+html[data-theme='coolvibe'] .settings-btn:hover,
+html[data-theme='coolvibe'] .new-chat-arrow:hover {
+ background: rgba(8, 145, 178, 0.16);
+ color: #0b5060;
+}
+
+html[data-theme='coolvibe'] .session-list-empty,
+html[data-theme='coolvibe'] .attachment-tray-note {
+ background: rgba(249, 253, 254, 0.92);
+ border-color: rgba(191, 220, 228, 0.96);
+}
+
+html[data-theme='coolvibe'] .session-item {
+ border: 1px solid transparent;
+ border-radius: 14px;
+}
+
+html[data-theme='coolvibe'] .session-item:hover {
+ background: rgba(8, 145, 178, 0.08);
+ border-color: rgba(191, 220, 228, 0.82);
+}
+
+html[data-theme='coolvibe'] .session-item.active {
+ background:
+ linear-gradient(135deg, rgba(8, 145, 178, 0.12), rgba(8, 145, 178, 0.04));
+ border-color: rgba(8, 145, 178, 0.26);
+ box-shadow: inset 3px 0 0 #08b7d6;
+}
+
+html[data-theme='coolvibe'] .session-item.active .session-item-title {
+ color: #0a6d83;
+}
+
+html[data-theme='coolvibe'] .chat-main {
+ background:
+ radial-gradient(circle at top, rgba(8, 145, 178, 0.08), transparent 34%),
+ linear-gradient(180deg, rgba(251, 254, 255, 0.92), rgba(238, 247, 249, 0.96));
+}
+
+html[data-theme='coolvibe'] .chat-header {
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.74), rgba(244, 250, 252, 0.88));
+ border-bottom-color: rgba(191, 220, 228, 0.96);
+ box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.84);
+}
+
+html[data-theme='coolvibe'] .chat-title {
+ font-weight: 800;
+ letter-spacing: -0.02em;
+}
+
+html[data-theme='coolvibe'] .chat-title:hover {
+ background: rgba(8, 145, 178, 0.08);
+}
+
+html[data-theme='coolvibe'] .chat-agent-btn,
+html[data-theme='coolvibe'] .mode-select {
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(234, 247, 251, 0.92));
+ border-color: rgba(167, 205, 216, 0.92);
+ color: #0c6478;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.94);
+}
+
+html[data-theme='coolvibe'] .chat-agent-btn:hover,
+html[data-theme='coolvibe'] .mode-select:hover {
+ background: rgba(228, 244, 248, 0.98);
+}
+
+html[data-theme='coolvibe'] .chat-runtime-state {
+ background: rgba(46, 138, 97, 0.12);
+ border-color: rgba(46, 138, 97, 0.22);
+ color: #226547;
+}
+
+html[data-theme='coolvibe'] .chat-cwd {
+ background: rgba(8, 145, 178, 0.08);
+ border: 1px solid rgba(167, 205, 216, 0.78);
+ color: #46606a;
+}
+
+html[data-theme='coolvibe'] .input-area {
+ background:
+ linear-gradient(180deg, rgba(251, 254, 255, 0.96), rgba(240, 248, 250, 0.96));
+ border-top-color: rgba(191, 220, 228, 0.96);
+}
+
+html[data-theme='coolvibe'] .input-wrapper {
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(245, 251, 253, 0.96));
+ border-color: rgba(167, 205, 216, 0.96);
+ border-radius: 18px;
+ box-shadow:
+ 0 10px 28px rgba(11, 73, 92, 0.1),
+ inset 0 1px 0 rgba(255, 255, 255, 0.92);
+}
+
+html[data-theme='coolvibe'] .input-wrapper:focus-within {
+ border-color: rgba(8, 145, 178, 0.56);
+ box-shadow:
+ 0 14px 30px rgba(8, 145, 178, 0.12),
+ 0 0 0 4px rgba(8, 145, 178, 0.08);
+}
+
+html[data-theme='coolvibe'] .msg.user .msg-bubble {
+ background:
+ linear-gradient(135deg, #0891b2, #11b8d7);
+ box-shadow: 0 12px 22px rgba(8, 145, 178, 0.16);
+}
+
+html[data-theme='coolvibe'] .msg.assistant .msg-bubble {
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 252, 253, 0.96));
+ border-color: rgba(191, 220, 228, 0.96);
+ box-shadow: 0 10px 24px rgba(11, 73, 92, 0.08);
+}
+
+html[data-theme='coolvibe'] .settings-actions .btn-save {
+ background:
+ linear-gradient(135deg, #0a8fb0, #11b8d7);
+ box-shadow: 0 10px 22px rgba(8, 145, 178, 0.18);
+}
+
+html[data-theme='coolvibe'] .settings-actions .btn-test {
+ background: rgba(8, 145, 178, 0.08);
+ border-color: rgba(167, 205, 216, 0.9);
+ color: #0c6478;
+}
+
+html[data-theme='coolvibe'] .theme-card:hover {
+ border-color: rgba(8, 145, 178, 0.32);
+ box-shadow: 0 10px 24px rgba(8, 145, 178, 0.1);
+}
+
+html[data-theme='coolvibe'] .theme-card.active {
+ box-shadow: 0 0 0 2px rgba(8, 145, 178, 0.14);
+}
+
+html[data-theme='editorial'] {
+ --bg-primary: #f6f1e8;
+ --bg-secondary: #efe8dc;
+ --bg-tertiary: #e2d8c7;
+ --bg-bubble-user: #2f4b45;
+ --bg-bubble-assistant: #fbf7f0;
+ --text-primary: #201b17;
+ --text-secondary: #5a5148;
+ --text-muted: #8a7d71;
+ --border-color: #d8ccba;
+ --accent: #8b5e3c;
+ --accent-hover: #71482d;
+ --accent-light: rgba(139, 94, 60, 0.14);
+ --success: #4d7b57;
+ --danger: #c05c42;
+ --info: #4f6f87;
+ --scrollbar-thumb: #bba995;
+ --font-ui: 'Avenir Next', 'Segoe UI', 'PingFang SC', sans-serif;
+ --page-background:
+ radial-gradient(circle at top left, rgba(139, 94, 60, 0.08), transparent 38%),
+ linear-gradient(180deg, #f8f3eb, #f2ebe0 48%, #f6f1e8);
+ --login-background:
+ linear-gradient(135deg, #f7f1e7 0%, #ede4d6 50%, #e6dbcb 100%);
+ --surface-strong: #fffdf8;
+ --shadow-strong: 0 14px 36px rgba(50, 32, 20, 0.1);
+ --theme-card-bg: rgba(255, 252, 246, 0.88);
+ --theme-card-border: rgba(216, 204, 186, 0.96);
+ --loading-overlay-layer-a: rgba(247, 241, 231, 0.8);
+ --loading-overlay-layer-b: rgba(230, 219, 203, 0.84);
+ --loading-overlay-scrim: rgba(46, 36, 27, 0.16);
+ --loading-card-bg: linear-gradient(180deg, rgba(255, 252, 247, 0.98), rgba(246, 238, 225, 0.96));
+ --loading-card-border: rgba(139, 94, 60, 0.16);
+ --loading-card-shadow: 0 24px 60px rgba(50, 32, 20, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.8);
+ --loading-badge-bg: rgba(139, 94, 60, 0.12);
+ --loading-badge-text: #8b5e3c;
+ --loading-bar-bg: rgba(216, 204, 186, 0.92);
+ --loading-bar-fill: linear-gradient(90deg, #8b5e3c, #c59470, #8b5e3c);
}
/* === Reset === */
@@ -39,11 +341,11 @@ body {
height: 100%;
height: 100dvh;
min-height: -webkit-fill-available;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Hiragino Sans', 'Noto Sans CJK SC', Roboto, sans-serif;
+ font-family: var(--font-ui);
font-size: 15px;
line-height: 1.6;
color: var(--text-primary);
- background: var(--bg-primary);
+ background: var(--page-background);
overflow: hidden;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
@@ -57,18 +359,18 @@ body {
.login-overlay {
position: fixed; inset: 0;
display: flex; align-items: center; justify-content: center;
- background: linear-gradient(135deg, #faf6f0 0%, #f0e8dc 50%, #e8dccf 100%);
+ background: var(--login-background);
z-index: 1000;
}
.login-box {
text-align: center;
padding: 48px 36px;
- background: #fff;
+ background: var(--surface-strong);
border: 1px solid var(--border-color);
border-radius: 20px;
width: 90%;
max-width: 360px;
- box-shadow: 0 8px 32px rgba(45, 31, 20, 0.08);
+ box-shadow: var(--shadow-strong);
}
.login-logo {
width: 64px; height: 64px;
@@ -149,6 +451,96 @@ body {
overflow: hidden;
}
+body.session-loading-active {
+ cursor: progress;
+}
+
+.session-loading-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 900;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+ background:
+ linear-gradient(135deg, var(--loading-overlay-layer-a), var(--loading-overlay-layer-b)),
+ var(--loading-overlay-scrim);
+ backdrop-filter: blur(10px);
+}
+
+.session-loading-card {
+ width: min(440px, 100%);
+ padding: 24px 24px 20px;
+ border: 1px solid var(--loading-card-border);
+ border-radius: 20px;
+ background: var(--loading-card-bg);
+ box-shadow: var(--loading-card-shadow);
+}
+
+.session-loading-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 74px;
+ margin-bottom: 12px;
+ padding: 5px 10px;
+ border-radius: 999px;
+ background: var(--loading-badge-bg);
+ color: var(--loading-badge-text);
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.session-loading-title {
+ font-size: 24px;
+ font-weight: 800;
+ letter-spacing: 0.01em;
+ color: var(--text-primary);
+}
+
+.session-loading-label {
+ margin-top: 8px;
+ color: var(--text-secondary);
+ font-size: 14px;
+ line-height: 1.7;
+}
+
+.session-loading-bar {
+ margin-top: 18px;
+ height: 8px;
+ overflow: hidden;
+ border-radius: 999px;
+ background: var(--loading-bar-bg);
+}
+
+.session-loading-bar-fill {
+ display: block;
+ width: 44%;
+ height: 100%;
+ border-radius: inherit;
+ background: var(--loading-bar-fill);
+ animation: session-loading-pulse 1.2s ease-in-out infinite;
+ transform-origin: left center;
+}
+
+@keyframes session-loading-pulse {
+ 0% {
+ transform: translateX(-55%) scaleX(0.82);
+ opacity: 0.72;
+ }
+ 50% {
+ transform: translateX(120%) scaleX(1.08);
+ opacity: 1;
+ }
+ 100% {
+ transform: translateX(250%) scaleX(0.88);
+ opacity: 0.72;
+ }
+}
+
/* === Sidebar === */
.sidebar {
width: var(--sidebar-width);
@@ -165,8 +557,40 @@ body {
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
+.agent-switch {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 4px;
+ padding: 4px;
+ margin-bottom: 10px;
+ background: rgba(255, 249, 242, 0.85);
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+}
+.agent-switch-btn {
+ appearance: none;
+ border: none;
+ border-radius: 9px;
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: 13px;
+ font-weight: 700;
+ padding: 8px 10px;
+ cursor: pointer;
+ transition: background 0.18s, color 0.18s, transform 0.18s;
+}
+.agent-switch-btn:hover {
+ background: rgba(233, 224, 212, 0.7);
+ color: var(--text-primary);
+}
+.agent-switch-btn.active {
+ background: var(--accent);
+ color: #fff;
+ box-shadow: 0 4px 14px rgba(192, 85, 58, 0.18);
+}
.new-chat-btn {
width: 100%;
+ min-height: 48px;
padding: 10px;
background: var(--accent);
border: none;
@@ -176,6 +600,9 @@ body {
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
}
.new-chat-btn:hover { background: var(--accent-hover); }
.session-list {
@@ -184,6 +611,16 @@ body {
padding: 8px;
-webkit-overflow-scrolling: touch;
}
+.session-list-empty {
+ margin: 12px 6px;
+ padding: 16px 14px;
+ border: 1px dashed var(--border-color);
+ border-radius: 12px;
+ color: var(--text-secondary);
+ font-size: 13px;
+ line-height: 1.6;
+ background: rgba(255, 249, 242, 0.7);
+}
.session-item {
display: flex;
align-items: center;
@@ -196,6 +633,13 @@ body {
}
.session-item:hover { background: var(--bg-tertiary); }
.session-item.active { background: var(--accent-light); }
+.session-item-main {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
.session-item-title {
flex: 1;
overflow: hidden;
@@ -205,6 +649,29 @@ body {
color: var(--text-primary);
}
.session-item.active .session-item-title { color: var(--accent); font-weight: 500; }
+.session-item-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ flex-shrink: 0;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: rgba(232, 190, 92, 0.16);
+ border: 1px solid rgba(212, 163, 58, 0.28);
+ color: #9a6f14;
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+.session-item-status::before {
+ content: '';
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+ animation: pulse 1.1s infinite;
+}
.session-item-time {
font-size: 11px;
color: var(--text-muted);
@@ -269,6 +736,7 @@ body {
background: var(--bg-secondary);
gap: 10px;
flex-shrink: 0;
+ position: relative;
}
.menu-btn {
display: none;
@@ -283,6 +751,7 @@ body {
.menu-btn:hover { background: var(--bg-tertiary); }
.chat-title {
flex: 1;
+ min-width: 0;
font-weight: 600;
font-size: 15px;
overflow: hidden;
@@ -295,13 +764,94 @@ body {
transition: background 0.15s;
}
.chat-title:hover { background: var(--bg-tertiary); }
-.cost-display {
- font-size: 12px;
- color: var(--text-muted);
+.chat-agent-btn {
+ appearance: none;
+ font-size: 11px;
+ color: var(--text-primary);
+ background: var(--accent-light);
+ border: 1px solid rgba(192, 85, 58, 0.2);
+ padding: 2px 22px 2px 10px;
+ border-radius: 999px;
flex-shrink: 0;
- background: var(--bg-tertiary);
- padding: 2px 8px;
- border-radius: 6px;
+ font-weight: 700;
+ cursor: pointer;
+ position: relative;
+}
+.chat-agent-btn::after {
+ content: '▾';
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-48%);
+ font-size: 10px;
+ color: var(--text-secondary);
+}
+.chat-agent-btn:hover {
+ background: #f1dfd7;
+}
+.chat-agent-menu {
+ position: absolute;
+ top: calc(100% - 6px);
+ right: 16px;
+ min-width: 148px;
+ padding: 8px;
+ border: 1px solid var(--border-color);
+ border-radius: 14px;
+ background: rgba(255, 252, 248, 0.98);
+ box-shadow: 0 18px 40px rgba(45, 31, 20, 0.14);
+ backdrop-filter: blur(14px);
+ z-index: 80;
+}
+.chat-agent-option {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 9px 11px;
+ border: none;
+ border-radius: 10px;
+ background: transparent;
+ color: var(--text-primary);
+ font-size: 13px;
+ font-weight: 700;
+ cursor: pointer;
+}
+.chat-agent-option:hover {
+ background: rgba(233, 224, 212, 0.65);
+}
+.chat-agent-option.active {
+ background: var(--accent-light);
+ color: var(--accent);
+}
+.chat-agent-option.active::after {
+ content: '当前';
+ font-size: 10px;
+ letter-spacing: 0.04em;
+}
+.chat-runtime-state {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 10px;
+ border-radius: 999px;
+ background: rgba(232, 190, 92, 0.16);
+ border: 1px solid rgba(212, 163, 58, 0.28);
+ color: #9a6f14;
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.04em;
+ flex-shrink: 0;
+}
+.chat-runtime-state::before {
+ content: '';
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: currentColor;
+ animation: pulse 1.1s infinite;
+}
+.cost-display {
+ display: none !important;
}
.cost-display:empty { display: none; }
@@ -474,11 +1024,33 @@ body {
min-width: 0;
max-width: 100%;
}
+.msg-attachments {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 10px;
+}
+.msg-attachment-label {
+ display: inline-flex;
+ align-items: center;
+ max-width: 100%;
+ padding: 5px 9px;
+ border-radius: 999px;
+ background: rgba(91, 126, 161, 0.12);
+ color: var(--text-secondary);
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1.35;
+}
.msg.user .msg-bubble {
background: var(--bg-bubble-user);
color: #fff;
border-bottom-right-radius: 4px;
}
+.msg.user .msg-attachment-label {
+ background: rgba(255, 255, 255, 0.16);
+ color: rgba(255, 255, 255, 0.92);
+}
.msg.assistant .msg-bubble {
background: var(--bg-bubble-assistant);
border: 1px solid var(--border-color);
@@ -621,6 +1193,16 @@ body {
border-radius: 10px;
overflow: hidden;
}
+.tool-call.codex-command {
+ border-color: rgba(91, 126, 161, 0.24);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
+}
+.tool-call.codex-reasoning {
+ border-color: rgba(192, 85, 58, 0.16);
+}
+.tool-call.codex-file-change {
+ border-color: rgba(93, 138, 84, 0.24);
+}
.tool-call summary {
padding: 8px 12px;
cursor: pointer;
@@ -628,11 +1210,61 @@ body {
color: var(--text-secondary);
background: var(--bg-secondary);
display: flex;
- align-items: center;
+ align-items: flex-start;
gap: 8px;
user-select: none;
list-style: none;
}
+.tool-call-summary-main {
+ min-width: 0;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+}
+.tool-call-label {
+ display: block;
+ width: 100%;
+ font-weight: 700;
+ color: var(--text-primary);
+ line-height: 1.25;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.tool-call-subtitle {
+ display: block;
+ width: 100%;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--text-muted);
+ font-size: 12px;
+}
+.tool-call-state {
+ flex-shrink: 0;
+ padding: 2px 8px;
+ border-radius: 999px;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ background: rgba(233, 224, 212, 0.85);
+ color: var(--text-secondary);
+}
+.tool-call-state.running {
+ background: rgba(232, 190, 92, 0.16);
+ color: #9a6f14;
+}
+.tool-call-state.done {
+ background: rgba(93, 138, 84, 0.14);
+ color: var(--success);
+}
+.tool-call-state.error {
+ background: rgba(192, 85, 58, 0.14);
+ color: var(--danger);
+}
.tool-call summary::-webkit-details-marker { display: none; }
.tool-call summary::before {
content: '▸';
@@ -664,6 +1296,49 @@ body {
word-break: break-all;
font-family: 'SF Mono', monospace;
}
+.tool-call-content.reasoning {
+ font-family: inherit;
+ line-height: 1.7;
+ color: var(--text-primary);
+ background: linear-gradient(180deg, rgba(255, 249, 242, 0.92), rgba(245, 221, 212, 0.32));
+}
+.tool-call-content.command,
+.tool-call-content.file-change {
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(250, 246, 240, 0.92));
+}
+.tool-call-structured {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.tool-call-section {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.tool-call-section-label {
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+}
+.tool-call-code {
+ margin: 0;
+ padding: 10px 12px;
+ border-radius: 10px;
+ background: rgba(45, 31, 20, 0.06);
+ color: var(--text-primary);
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
+ font-size: 12px;
+ line-height: 1.6;
+}
+.tool-call-empty {
+ color: var(--text-muted);
+ font-style: italic;
+}
/* Tool group (auto-fold) */
.tool-group {
@@ -877,6 +1552,72 @@ body {
border-top: 1px solid var(--border-color);
flex-shrink: 0;
}
+.attachment-tray {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ max-width: 800px;
+ margin: 0 auto 10px;
+}
+.attachment-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ max-width: 100%;
+ padding: 7px 10px;
+ border: 1px solid rgba(91, 126, 161, 0.2);
+ border-radius: 12px;
+ background: rgba(255, 252, 248, 0.96);
+ color: var(--text-primary);
+ font-size: 12px;
+ line-height: 1.4;
+}
+.attachment-chip.uploading {
+ border-style: dashed;
+ border-color: rgba(91, 126, 161, 0.34);
+ background: rgba(246, 249, 252, 0.96);
+}
+.attachment-chip-meta {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+.attachment-chip-name {
+ font-weight: 700;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.attachment-chip-note {
+ color: var(--text-muted);
+ font-size: 11px;
+ white-space: nowrap;
+}
+.attachment-chip-remove {
+ appearance: none;
+ border: none;
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 13px;
+ line-height: 1;
+ padding: 2px;
+ border-radius: 6px;
+}
+.attachment-chip-remove:hover {
+ color: var(--danger);
+ background: rgba(192, 85, 58, 0.1);
+}
+.attachment-tray-note {
+ width: 100%;
+ padding: 8px 10px;
+ border-radius: 10px;
+ background: rgba(255, 246, 223, 0.95);
+ border: 1px solid rgba(232, 190, 92, 0.3);
+ color: #8b6420;
+ font-size: 12px;
+ line-height: 1.55;
+}
.input-wrapper {
display: flex;
align-items: flex-end;
@@ -889,6 +1630,35 @@ body {
margin: 0 auto;
transition: border-color 0.2s, box-shadow 0.2s;
}
+.input-wrapper.drag-active {
+ border-color: var(--info);
+ box-shadow: 0 0 0 3px rgba(91, 126, 161, 0.12);
+}
+.attach-btn {
+ appearance: none;
+ width: 40px;
+ height: 40px;
+ border: 1px solid transparent;
+ background: transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ padding: 0;
+ border-radius: 12px;
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ transition: color 0.18s ease, background 0.18s ease, border-color 0.18s ease;
+}
+.attach-btn:hover {
+ color: var(--accent);
+ background: rgba(192, 85, 58, 0.08);
+ border-color: rgba(192, 85, 58, 0.12);
+}
+.attach-btn:disabled {
+ opacity: 0.45;
+ cursor: default;
+}
.input-wrapper:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-light);
@@ -1009,6 +1779,19 @@ body {
.session-item-actions { display: flex; }
.cmd-menu { left: 10px; right: 10px; transform: none; min-width: auto; bottom: 72px; }
.option-picker { left: 10px; right: 10px; transform: none; min-width: auto; bottom: 72px; }
+ .chat-title {
+ font-size: 13px;
+ }
+ .theme-grid {
+ grid-template-columns: 1fr;
+ }
+ .chat-agent-menu {
+ right: 10px;
+ min-width: 138px;
+ }
+ .chat-cwd {
+ max-width: 120px;
+ }
}
@media (max-width: 480px) {
@@ -1018,7 +1801,11 @@ body {
.msg.assistant .msg-bubble { border-bottom-left-radius: 4px; }
.code-block-wrapper pre code { font-size: 12px; }
.input-wrapper { padding: 6px 10px; border-radius: 12px; }
+ .attach-btn { width: 38px; height: 38px; border-radius: 11px; }
.send-btn, .abort-btn { width: 34px; height: 34px; }
+ .new-chat-btn,
+ .new-chat-arrow { min-height: 44px; }
+ .new-chat-arrow { width: 48px; }
}
/* === Utility === */
@@ -1117,10 +1904,50 @@ body {
gap: 8px;
margin-bottom: 20px;
}
+.settings-back {
+ appearance: none;
+ width: 34px;
+ height: 34px;
+ border: 1px solid var(--border-color);
+ border-radius: 10px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ cursor: pointer;
+ font-size: 22px;
+ line-height: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+.settings-back:hover {
+ background: var(--bg-tertiary);
+}
.settings-header h3 {
margin-bottom: 0;
flex: 1;
}
+.settings-subpage-header {
+ align-items: flex-start;
+}
+.settings-subpage-copy {
+ flex: 1;
+ min-width: 0;
+}
+.settings-subpage-copy h3 {
+ margin-bottom: 0;
+}
+.settings-subpage-kicker {
+ display: inline-flex;
+ align-items: center;
+ min-height: 20px;
+ margin-bottom: 4px;
+ color: var(--text-muted);
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
.settings-header .settings-close {
margin-left: auto;
background: none;
@@ -1135,6 +1962,37 @@ body {
.settings-field {
margin-bottom: 16px;
}
+.agent-context-card {
+ margin: -6px 0 18px;
+ padding: 14px 16px 15px;
+ border: 1px solid rgba(192, 85, 58, 0.14);
+ border-radius: 14px;
+ background:
+ radial-gradient(circle at top right, rgba(192, 85, 58, 0.12), transparent 45%),
+ linear-gradient(135deg, rgba(245, 221, 212, 0.65), rgba(255, 249, 242, 0.96));
+}
+.agent-context-kicker {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--accent);
+ margin-bottom: 6px;
+}
+.agent-context-title {
+ font-size: 15px;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin-bottom: 4px;
+}
+.agent-context-copy {
+ font-size: 13px;
+ color: var(--text-secondary);
+ line-height: 1.65;
+}
.settings-field label {
display: block;
font-size: 13px;
@@ -1142,6 +2000,120 @@ body {
color: var(--text-secondary);
margin-bottom: 6px;
}
+.settings-inline-note {
+ margin: -4px 0 16px;
+ padding: 10px 12px;
+ border-radius: 10px;
+ font-size: 12px;
+ line-height: 1.65;
+ color: var(--text-secondary);
+ background: rgba(255, 249, 242, 0.9);
+ border: 1px solid rgba(221, 208, 192, 0.9);
+}
+.settings-inline-note.warning {
+ color: #8b6420;
+ background: rgba(255, 246, 223, 0.95);
+ border-color: rgba(232, 190, 92, 0.3);
+}
+.settings-inline-note code {
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
+ font-size: 11px;
+ padding: 1px 5px;
+ border-radius: 6px;
+ background: rgba(45, 31, 20, 0.08);
+ color: var(--text-primary);
+}
+.settings-nav-card {
+ appearance: none;
+ width: 100%;
+ padding: 14px 16px;
+ border: 1px solid var(--border-color);
+ border-radius: 14px;
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 249, 242, 0.92));
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ text-align: left;
+ cursor: pointer;
+ transition: border-color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
+}
+.settings-nav-card:hover {
+ transform: translateY(-1px);
+ border-color: rgba(192, 85, 58, 0.24);
+ box-shadow: 0 12px 24px rgba(45, 31, 20, 0.06);
+}
+.settings-nav-card-main {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+.settings-nav-card-title {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+.settings-nav-card-meta {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+.settings-nav-card-arrow {
+ color: var(--text-muted);
+ font-size: 22px;
+ line-height: 1;
+ flex-shrink: 0;
+}
+.settings-subpage-panel {
+ max-width: 460px;
+}
+.theme-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+ margin-bottom: 18px;
+}
+.theme-card {
+ appearance: none;
+ border: 1px solid var(--theme-card-border);
+ border-radius: 16px;
+ background: var(--theme-card-bg);
+ padding: 12px;
+ text-align: left;
+ cursor: pointer;
+ transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
+}
+.theme-card:hover {
+ transform: translateY(-2px);
+ border-color: rgba(192, 85, 58, 0.32);
+}
+.theme-card.active {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px rgba(192, 85, 58, 0.12);
+}
+.theme-card-preview {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 6px;
+ margin-bottom: 10px;
+}
+.theme-card-swatch {
+ height: 18px;
+ border-radius: 999px;
+ border: 1px solid rgba(0, 0, 0, 0.06);
+}
+.theme-card-title {
+ font-size: 13px;
+ font-weight: 800;
+ color: var(--text-primary);
+ margin-bottom: 2px;
+}
+.theme-card-desc {
+ font-size: 11px;
+ color: var(--text-secondary);
+ line-height: 1.45;
+}
.settings-field input,
.settings-select {
width: 100%;
@@ -1207,6 +2179,8 @@ body {
}
@media (max-width: 480px) {
.settings-panel { width: 95%; padding: 20px 16px; }
+ .settings-nav-card { padding: 13px 14px; }
+ .settings-back { width: 32px; height: 32px; }
}
/* === Force Change Password Overlay === */
@@ -1313,14 +2287,24 @@ body {
/* === New Chat Split Button === */
.new-chat-split {
display: flex;
- gap: 4px;
+ align-items: stretch;
+ gap: 6px;
+}
+.new-chat-split.single .new-chat-btn {
+ border-radius: 10px;
+}
+.new-chat-split.single .new-chat-arrow {
+ display: none;
}
.new-chat-split .new-chat-btn {
flex: 1;
+ min-height: 48px;
border-radius: 10px 0 0 10px;
}
.new-chat-arrow {
- padding: 0 10px;
+ width: 52px;
+ min-height: 48px;
+ padding: 0;
background: var(--accent);
border: none;
border-radius: 0 10px 10px 0;
@@ -1329,6 +2313,9 @@ body {
cursor: pointer;
transition: background 0.2s;
flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
}
.new-chat-arrow:hover { background: var(--accent-hover); }
.new-chat-dropdown {
@@ -1356,6 +2343,27 @@ body {
transition: background 0.12s;
}
.new-chat-dropdown button:hover { background: var(--accent-light); }
+
+html[data-theme='coolvibe'] .settings-nav-card {
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(243, 250, 252, 0.94));
+ border-color: rgba(191, 220, 228, 0.96);
+}
+
+html[data-theme='coolvibe'] .settings-nav-card:hover {
+ border-color: rgba(8, 145, 178, 0.28);
+ box-shadow: 0 12px 24px rgba(8, 145, 178, 0.08);
+}
+
+html[data-theme='coolvibe'] .settings-back {
+ background: rgba(8, 145, 178, 0.08);
+ border-color: rgba(167, 205, 216, 0.84);
+ color: #0c6478;
+}
+
+html[data-theme='coolvibe'] .settings-back:hover {
+ background: rgba(8, 145, 178, 0.16);
+}
.sidebar-header { position: relative; }
/* === Chat CWD label === */
@@ -1366,7 +2374,7 @@ body {
padding: 2px 8px;
border-radius: 6px;
flex-shrink: 0;
- max-width: 200px;
+ max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1446,6 +2454,24 @@ body {
display: flex;
gap: 8px;
}
+.modal-select {
+ width: 100%;
+ padding: 10px 12px;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 10px;
+ color: var(--text-primary);
+ font-size: 14px;
+ font-family: inherit;
+ outline: none;
+ transition: border-color 0.2s;
+}
+.modal-select:focus { border-color: var(--accent); }
+.modal-stack {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
.modal-text-input {
flex: 1;
padding: 10px 12px;
@@ -1523,6 +2549,22 @@ body {
white-space: nowrap;
margin-bottom: 2px;
}
+.import-item-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 6px;
+}
+.import-item-tag {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ color: var(--accent);
+ background: rgba(245, 221, 212, 0.9);
+ border: 1px solid rgba(192, 85, 58, 0.14);
+ border-radius: 999px;
+ padding: 2px 7px;
+}
.import-item-meta {
font-size: 12px;
color: var(--text-muted);
diff --git a/scripts/mock-claude.js b/scripts/mock-claude.js
new file mode 100755
index 0000000..815f725
--- /dev/null
+++ b/scripts/mock-claude.js
@@ -0,0 +1,52 @@
+#!/usr/bin/env node
+
+const crypto = require('crypto');
+
+function readStdin() {
+ return new Promise((resolve) => {
+ let data = '';
+ process.stdin.setEncoding('utf8');
+ process.stdin.on('data', (chunk) => { data += chunk; });
+ process.stdin.on('end', () => resolve(data));
+ });
+}
+
+(async function main() {
+ const args = process.argv.slice(2);
+ const resumeIndex = args.indexOf('--resume');
+ const inputFormatIndex = args.indexOf('--input-format');
+ const sessionId = resumeIndex >= 0 && args[resumeIndex + 1]
+ ? args[resumeIndex + 1]
+ : crypto.randomUUID();
+
+ const input = (await readStdin()).trim();
+ const usesStreamJson = inputFormatIndex >= 0 && args[inputFormatIndex + 1] === 'stream-json';
+
+ process.stdout.write(`${JSON.stringify({ type: 'system', session_id: sessionId })}\n`);
+
+ let text = '';
+ if (usesStreamJson) {
+ let payload = null;
+ try { payload = JSON.parse(input.split('\n').find(Boolean) || '{}'); } catch {}
+ const blocks = payload?.message?.content || [];
+ const imageCount = blocks.filter((block) => block.type === 'image').length;
+ const promptText = blocks.filter((block) => block.type === 'text').map((block) => block.text || '').join(' ').trim();
+ text = `Claude mock handled stream-json (${imageCount} image): ${promptText || '[no text]'}`;
+ } else if (input === '/compact') {
+ text = 'Claude compact finished.';
+ } else {
+ text = `Claude mock handled: ${input}`;
+ }
+
+ process.stdout.write(`${JSON.stringify({
+ type: 'assistant',
+ session_id: sessionId,
+ message: { content: [{ type: 'text', text }] },
+ })}\n`);
+
+ process.stdout.write(`${JSON.stringify({
+ type: 'result',
+ session_id: sessionId,
+ total_cost_usd: 0,
+ })}\n`);
+})();
diff --git a/scripts/mock-codex.js b/scripts/mock-codex.js
new file mode 100755
index 0000000..6de43bd
--- /dev/null
+++ b/scripts/mock-codex.js
@@ -0,0 +1,62 @@
+#!/usr/bin/env node
+
+const crypto = require('crypto');
+
+function readStdin() {
+ return new Promise((resolve) => {
+ let data = '';
+ process.stdin.setEncoding('utf8');
+ process.stdin.on('data', (chunk) => { data += chunk; });
+ process.stdin.on('end', () => resolve(data));
+ });
+}
+
+(async function main() {
+ const args = process.argv.slice(2);
+ const isResume = args[0] === 'exec' && args[1] === 'resume';
+ const threadId = isResume && args[2] ? args[2] : `mock-${crypto.randomUUID()}`;
+ const input = (await readStdin()).trim();
+ const imageCount = args.filter((arg) => arg === '--image').length;
+
+ process.stdout.write(`${JSON.stringify({ type: 'thread.started', thread_id: threadId })}\n`);
+ process.stdout.write(`${JSON.stringify({ type: 'turn.started' })}\n`);
+
+ if (/pwd/i.test(input)) {
+ process.stdout.write(`${JSON.stringify({
+ type: 'item.started',
+ item: {
+ id: 'item_cmd',
+ type: 'command_execution',
+ command: '/bin/bash -lc pwd',
+ aggregated_output: '',
+ exit_code: null,
+ status: 'in_progress',
+ },
+ })}\n`);
+ process.stdout.write(`${JSON.stringify({
+ type: 'item.completed',
+ item: {
+ id: 'item_cmd',
+ type: 'command_execution',
+ command: '/bin/bash -lc pwd',
+ aggregated_output: '/tmp/mock-codex\n',
+ exit_code: 0,
+ status: 'completed',
+ },
+ })}\n`);
+ }
+
+ process.stdout.write(`${JSON.stringify({
+ type: 'item.completed',
+ item: {
+ id: 'item_msg',
+ type: 'agent_message',
+ text: `Codex mock handled (${imageCount} image): ${input}`,
+ },
+ })}\n`);
+
+ process.stdout.write(`${JSON.stringify({
+ type: 'turn.completed',
+ usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 5 },
+ })}\n`);
+})();
diff --git a/scripts/regression.js b/scripts/regression.js
new file mode 100644
index 0000000..79433d5
--- /dev/null
+++ b/scripts/regression.js
@@ -0,0 +1,403 @@
+#!/usr/bin/env node
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawn, spawnSync } = require('child_process');
+const WebSocket = require('ws');
+
+const REPO_DIR = path.resolve(__dirname, '..');
+const SERVER_PATH = path.join(REPO_DIR, 'server.js');
+const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js');
+const MOCK_CODEX = path.join(REPO_DIR, 'scripts', 'mock-codex.js');
+
+function mkdirp(dir) {
+ fs.mkdirSync(dir, { recursive: true });
+}
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function assert(condition, message) {
+ if (!condition) {
+ throw new Error(message);
+ }
+}
+
+function sql(dbPath, statement) {
+ const result = spawnSync('sqlite3', [dbPath, statement], { encoding: 'utf8' });
+ if (result.status !== 0) throw new Error(result.stderr || `sqlite3 failed: ${statement}`);
+ return result.stdout.trim();
+}
+
+async function waitForPort(port, timeoutMs = 10000) {
+ const started = Date.now();
+ while (Date.now() - started < timeoutMs) {
+ const probe = spawnSync('bash', ['-lc', `ss -tln | grep -q ':${port} '`], { encoding: 'utf8' });
+ if (probe.status === 0) return;
+ await sleep(100);
+ }
+ throw new Error(`Timed out waiting for port ${port}`);
+}
+
+async function withServer(env, fn) {
+ const child = spawn('/usr/bin/node', [SERVER_PATH], {
+ cwd: REPO_DIR,
+ env: { ...process.env, ...env },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let stdout = '';
+ let stderr = '';
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
+
+ try {
+ await waitForPort(env.PORT, 10000);
+ await fn({ child, stdout: () => stdout, stderr: () => stderr });
+ } finally {
+ child.kill('SIGTERM');
+ await sleep(300);
+ if (!child.killed) child.kill('SIGKILL');
+ }
+}
+
+function connectWs(port, password) {
+ return new Promise((resolve, reject) => {
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
+ const messages = [];
+
+ ws.on('open', () => {
+ ws.send(JSON.stringify({ type: 'auth', password }));
+ });
+ ws.on('message', (buf) => {
+ const msg = JSON.parse(String(buf));
+ messages.push(msg);
+ if (msg.type === 'auth_result' && msg.success) resolve({ ws, messages, token: msg.token });
+ if (msg.type === 'auth_result' && !msg.success) reject(new Error('Auth failed'));
+ });
+ ws.on('error', reject);
+ });
+}
+
+async function uploadAttachment(port, token, { filename, mime, data }) {
+ const response = await fetch(`http://127.0.0.1:${port}/api/attachments`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': mime,
+ 'X-Filename': encodeURIComponent(filename),
+ },
+ body: data,
+ });
+ const payload = await response.json();
+ assert(response.ok && payload.ok, `Attachment upload failed: ${payload.message || response.status}`);
+ return payload.attachment;
+}
+
+function nextMessage(messages, ws, predicate, timeoutMs = 5000) {
+ return new Promise((resolve, reject) => {
+ const started = Date.now();
+ const timer = setInterval(() => {
+ const found = messages.find(predicate);
+ if (found) {
+ clearInterval(timer);
+ resolve(found);
+ return;
+ }
+ if (Date.now() - started > timeoutMs) {
+ clearInterval(timer);
+ reject(new Error('Timed out waiting for expected WebSocket message'));
+ }
+ }, 50);
+ });
+}
+
+function createFakeClaudeHistory(homeDir) {
+ const projectDir = path.join(homeDir, '.claude', 'projects', 'tmp-project');
+ mkdirp(projectDir);
+ const sessionId = 'claude-import-test';
+ const filePath = path.join(projectDir, `${sessionId}.jsonl`);
+ const lines = [
+ JSON.stringify({
+ type: 'user',
+ cwd: '/tmp/project-a',
+ timestamp: '2026-03-12T00:00:00.000Z',
+ message: { content: 'Claude import prompt' },
+ }),
+ JSON.stringify({
+ type: 'assistant',
+ timestamp: '2026-03-12T00:00:02.000Z',
+ message: { content: [{ type: 'text', text: 'Claude import answer' }] },
+ }),
+ ];
+ fs.writeFileSync(filePath, `${lines.join('\n')}\n`);
+ return { sessionId, projectDir: 'tmp-project', filePath };
+}
+
+function createFakeCodexHistory(homeDir) {
+ const sessionsDir = path.join(homeDir, '.codex', 'sessions', '2026', '03', '12');
+ mkdirp(sessionsDir);
+ const threadId = 'codex-import-thread';
+ const rolloutPath = path.join(sessionsDir, 'rollout-2026-03-12T00-00-00-codex-import-thread.jsonl');
+ const rolloutLines = [
+ JSON.stringify({
+ timestamp: '2026-03-12T00:00:00.000Z',
+ type: 'session_meta',
+ payload: { id: threadId, cwd: '/tmp/project-b', cli_version: '0.114.0', source: 'exec' },
+ }),
+ JSON.stringify({
+ timestamp: '2026-03-12T00:00:00.100Z',
+ type: 'response_item',
+ payload: {
+ type: 'message',
+ role: 'user',
+ content: [{ type: 'input_text', text: '# AGENTS.md wrapper should be ignored' }],
+ },
+ }),
+ JSON.stringify({
+ timestamp: '2026-03-12T00:00:01.000Z',
+ type: 'event_msg',
+ payload: { type: 'user_message', message: 'Codex import prompt' },
+ }),
+ JSON.stringify({
+ timestamp: '2026-03-12T00:00:02.000Z',
+ type: 'response_item',
+ payload: {
+ type: 'message',
+ role: 'assistant',
+ content: [{ type: 'output_text', text: 'Codex import answer' }],
+ },
+ }),
+ JSON.stringify({
+ timestamp: '2026-03-12T00:00:03.000Z',
+ type: 'event_msg',
+ payload: {
+ type: 'token_count',
+ info: { total_token_usage: { input_tokens: 20, cached_input_tokens: 5, output_tokens: 8 } },
+ },
+ }),
+ ];
+ fs.writeFileSync(rolloutPath, `${rolloutLines.join('\n')}\n`);
+
+ const stateDb = path.join(homeDir, '.codex', 'state_5.sqlite');
+ mkdirp(path.dirname(stateDb));
+ sql(stateDb, `
+ PRAGMA journal_mode = WAL;
+ CREATE TABLE IF NOT EXISTS threads (
+ id TEXT PRIMARY KEY,
+ rollout_path TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ source TEXT NOT NULL,
+ model_provider TEXT NOT NULL,
+ cwd TEXT NOT NULL,
+ title TEXT NOT NULL,
+ sandbox_policy TEXT NOT NULL,
+ approval_mode TEXT NOT NULL,
+ tokens_used INTEGER NOT NULL DEFAULT 0,
+ has_user_event INTEGER NOT NULL DEFAULT 0,
+ archived INTEGER NOT NULL DEFAULT 0,
+ archived_at INTEGER,
+ git_sha TEXT,
+ git_branch TEXT,
+ git_origin_url TEXT,
+ cli_version TEXT NOT NULL DEFAULT '',
+ first_user_message TEXT NOT NULL DEFAULT '',
+ agent_nickname TEXT,
+ agent_role TEXT,
+ memory_mode TEXT NOT NULL DEFAULT 'enabled'
+ );
+ CREATE TABLE IF NOT EXISTS stage1_outputs (
+ thread_id TEXT PRIMARY KEY,
+ source_updated_at INTEGER NOT NULL,
+ raw_memory TEXT NOT NULL,
+ rollout_summary TEXT NOT NULL,
+ generated_at INTEGER NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS thread_dynamic_tools (
+ thread_id TEXT NOT NULL,
+ position INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ description TEXT NOT NULL,
+ input_schema TEXT NOT NULL,
+ PRIMARY KEY(thread_id, position)
+ );
+ CREATE TABLE IF NOT EXISTS logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ts INTEGER NOT NULL,
+ ts_nanos INTEGER NOT NULL,
+ level TEXT NOT NULL,
+ target TEXT NOT NULL,
+ message TEXT,
+ module_path TEXT,
+ file TEXT,
+ line INTEGER,
+ thread_id TEXT,
+ process_uuid TEXT,
+ estimated_bytes INTEGER NOT NULL DEFAULT 0
+ );
+ INSERT INTO threads (id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, sandbox_policy, approval_mode, cli_version)
+ VALUES ('${threadId}', '${rolloutPath.replace(/'/g, "''")}', 1, 2, 'exec', 'OpenAI', '/tmp/project-b', 'Codex import prompt', '{}', 'never', '0.114.0');
+ INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
+ `);
+
+ const logsDb = path.join(homeDir, '.codex', 'logs_1.sqlite');
+ sql(logsDb, `
+ CREATE TABLE IF NOT EXISTS logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ts INTEGER NOT NULL,
+ ts_nanos INTEGER NOT NULL,
+ level TEXT NOT NULL,
+ target TEXT NOT NULL,
+ message TEXT,
+ module_path TEXT,
+ file TEXT,
+ line INTEGER,
+ thread_id TEXT,
+ process_uuid TEXT,
+ estimated_bytes INTEGER NOT NULL DEFAULT 0
+ );
+ INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
+ `);
+
+ return { threadId, rolloutPath, stateDb, logsDb };
+}
+
+async function main() {
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
+ const configDir = path.join(tempRoot, 'config');
+ const sessionsDir = path.join(tempRoot, 'sessions');
+ const logsDir = path.join(tempRoot, 'logs');
+ const homeDir = path.join(tempRoot, 'home');
+ mkdirp(configDir);
+ mkdirp(sessionsDir);
+ mkdirp(logsDir);
+ mkdirp(homeDir);
+
+ fs.writeFileSync(path.join(configDir, 'notify.json'), JSON.stringify({
+ provider: 'off',
+ pushplus: { token: '' },
+ telegram: { botToken: '', chatId: '' },
+ serverchan: { sendKey: '' },
+ feishu: { webhook: '' },
+ qqbot: { qmsgKey: '' },
+ }, null, 2));
+
+ createFakeClaudeHistory(homeDir);
+ const codexFixture = createFakeCodexHistory(homeDir);
+
+ const port = 9102;
+ const password = 'Regression!234';
+
+ await withServer({
+ PORT: String(port),
+ CC_WEB_PASSWORD: password,
+ CC_WEB_CONFIG_DIR: configDir,
+ CC_WEB_SESSIONS_DIR: sessionsDir,
+ CC_WEB_LOGS_DIR: logsDir,
+ HOME: homeDir,
+ CLAUDE_PATH: MOCK_CLAUDE,
+ CODEX_PATH: MOCK_CODEX,
+ }, async () => {
+ const { ws, messages, token } = await connectWs(port, password);
+
+ await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
+
+ ws.send(JSON.stringify({
+ type: 'save_codex_config',
+ config: {
+ mode: 'custom',
+ activeProfile: 'Regression Profile',
+ profiles: [{ name: 'Regression Profile', apiKey: 'sk-regression', apiBase: 'https://example.com/v1' }],
+ enableSearch: true,
+ },
+ }));
+ const codexConfigMsg = await nextMessage(messages, ws, (msg) => msg.type === 'codex_config');
+ assert(codexConfigMsg.config.mode === 'custom', 'Codex config mode save/load failed');
+ assert(codexConfigMsg.config.activeProfile === 'Regression Profile', 'Codex active profile save/load failed');
+ assert(Array.isArray(codexConfigMsg.config.profiles) && codexConfigMsg.config.profiles[0]?.apiKey.includes('****'), 'Codex profile API key should be masked');
+ assert(codexConfigMsg.config.supportsSearch === false, 'Codex config should expose unsupported search capability');
+ assert(codexConfigMsg.config.enableSearch === false, 'Codex config should ignore unsupported search toggle');
+
+ ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: '/tmp/codex-space', mode: 'plan' }));
+ const codexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === '/tmp/codex-space');
+ assert(codexSession.mode === 'plan', 'Codex new_session should follow requested mode');
+ assert(codexSession.model === null, 'Codex new_session should not inject a default model');
+
+ ws.send(JSON.stringify({ type: 'message', text: '/model gpt-5.3-codex', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex' }));
+ const codexModelChanged = await nextMessage(messages, ws, (msg) => msg.type === 'model_changed' && msg.model === 'gpt-5.3-codex');
+ assert(codexModelChanged.model === 'gpt-5.3-codex', 'Codex /model should accept arbitrary Codex model names');
+
+ const codexAttachment = await uploadAttachment(port, token, {
+ filename: 'codex-test.png',
+ mime: 'image/png',
+ data: Buffer.from('codex-image'),
+ });
+ ws.send(JSON.stringify({ type: 'message', text: 'first codex prompt', attachments: [codexAttachment], mode: 'yolo', agent: 'codex' }));
+ const firstMessageSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'first codex prompt');
+ assert(firstMessageSession.agent === 'codex', 'First-message path created wrong agent');
+ const runningSessionList = await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === firstMessageSession.sessionId && s.isRunning));
+ assert(runningSessionList.sessions.some((s) => s.id === firstMessageSession.sessionId && s.isRunning), 'Running Codex session should be marked as isRunning');
+ await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId);
+ const processLog = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8');
+ const spawnLine = processLog
+ .trim()
+ .split('\n')
+ .find((line) => line.includes(`"event":"process_spawn"`) && line.includes(firstMessageSession.sessionId.slice(0, 8)));
+ assert(spawnLine && !spawnLine.includes('--search') && spawnLine.includes('--image'), 'Codex exec should attach images and not append unsupported --search flag');
+ const runtimeToml = fs.readFileSync(path.join(configDir, 'codex-runtime-home', 'config.toml'), 'utf8');
+ assert(runtimeToml.includes('preferred_auth_method = "apikey"'), 'Codex custom profile should write isolated runtime auth mode');
+ assert(runtimeToml.includes('base_url = "https://example.com/v1"'), 'Codex custom profile should write isolated runtime base_url');
+
+ const claudeAttachment = await uploadAttachment(port, token, {
+ filename: 'claude-test.png',
+ mime: 'image/png',
+ data: Buffer.from('claude-image'),
+ });
+ ws.send(JSON.stringify({ type: 'message', text: 'describe attachment', attachments: [claudeAttachment], mode: 'yolo', agent: 'claude' }));
+ const claudeImageSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'claude' && msg.title === 'describe attachment');
+ await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === claudeImageSession.sessionId);
+ const claudeSpawnLine = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8')
+ .trim()
+ .split('\n')
+ .find((line) => line.includes(`"event":"process_spawn"`) && line.includes(claudeImageSession.sessionId.slice(0, 8)));
+ assert(claudeSpawnLine && claudeSpawnLine.includes('--input-format stream-json'), 'Claude image message should switch stdin to stream-json');
+ const storedClaudeSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${claudeImageSession.sessionId}.json`), 'utf8'));
+ assert(Array.isArray(storedClaudeSession.messages?.[0]?.attachments) && storedClaudeSession.messages[0].attachments.length === 1, 'Claude message should persist attachment metadata');
+
+ ws.send(JSON.stringify({ type: 'list_native_sessions' }));
+ const nativeSessions = await nextMessage(messages, ws, (msg) => msg.type === 'native_sessions');
+ assert(nativeSessions.groups?.length > 0, 'Claude native session listing failed');
+ const firstClaude = nativeSessions.groups[0].sessions[0];
+ ws.send(JSON.stringify({ type: 'import_native_session', sessionId: firstClaude.sessionId, projectDir: nativeSessions.groups[0].dir }));
+ const importedClaude = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'claude' && msg.title === 'Claude import prompt');
+ assert(importedClaude.messages?.[0]?.content === 'Claude import prompt', 'Claude import parsed wrong first message');
+
+ ws.send(JSON.stringify({ type: 'list_codex_sessions' }));
+ const codexSessions = await nextMessage(messages, ws, (msg) => msg.type === 'codex_sessions');
+ const importedCodexItem = codexSessions.sessions.find((item) => item.threadId === codexFixture.threadId);
+ assert(importedCodexItem, 'Codex session listing failed');
+
+ ws.send(JSON.stringify({ type: 'import_codex_session', threadId: importedCodexItem.threadId, rolloutPath: importedCodexItem.rolloutPath }));
+ const importedCodex = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'Codex import prompt');
+ assert(importedCodex.messages?.[0]?.content === 'Codex import prompt', 'Codex import kept wrapper instructions');
+ assert(importedCodex.totalUsage?.inputTokens === 20, 'Codex import usage parse failed');
+
+ const importedSessionId = importedCodex.sessionId;
+ ws.send(JSON.stringify({ type: 'delete_session', sessionId: importedSessionId }));
+ await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && !msg.sessions.some((s) => s.id === importedSessionId));
+
+ assert(!fs.existsSync(path.join(sessionsDir, `${importedSessionId}.json`)), 'Deleting Codex session did not remove session JSON');
+ assert(!fs.existsSync(codexFixture.rolloutPath), 'Deleting Codex session did not remove rollout file');
+ assert(sql(codexFixture.stateDb, `select count(*) from threads where id='${codexFixture.threadId}'`) === '0', 'Deleting Codex session did not remove thread row');
+
+ ws.close();
+ console.log('Regression checks passed.');
+ });
+}
+
+main().catch((err) => {
+ console.error(err.stack || err.message);
+ process.exit(1);
+});
diff --git a/server.js b/server.js
index 3782c11..97629da 100644
--- a/server.js
+++ b/server.js
@@ -2,8 +2,10 @@ const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
-const { spawn } = require('child_process');
+const { spawn, spawnSync } = require('child_process');
const { WebSocketServer } = require('ws');
+const { createAgentRuntime } = require('./lib/agent-runtime');
+const { createCodexRolloutStore } = require('./lib/codex-rollouts');
// Load .env
const envPath = path.join(__dirname, '.env');
@@ -16,16 +18,25 @@ if (fs.existsSync(envPath)) {
const PORT = parseInt(process.env.PORT) || 8002;
const CLAUDE_PATH = process.env.CLAUDE_PATH || 'claude';
-const SESSIONS_DIR = path.join(__dirname, 'sessions');
-const PUBLIC_DIR = path.join(__dirname, 'public');
-const LOGS_DIR = path.join(__dirname, 'logs');
-const NOTIFY_CONFIG_PATH = path.join(__dirname, 'config', 'notify.json');
-const AUTH_CONFIG_PATH = path.join(__dirname, 'config', 'auth.json');
-const MODEL_CONFIG_PATH = path.join(__dirname, 'config', 'model.json');
+const CODEX_PATH = process.env.CODEX_PATH || 'codex';
+const CONFIG_DIR = process.env.CC_WEB_CONFIG_DIR || path.join(__dirname, 'config');
+const SESSIONS_DIR = process.env.CC_WEB_SESSIONS_DIR || path.join(__dirname, 'sessions');
+const PUBLIC_DIR = process.env.CC_WEB_PUBLIC_DIR || path.join(__dirname, 'public');
+const LOGS_DIR = process.env.CC_WEB_LOGS_DIR || path.join(__dirname, 'logs');
+const ATTACHMENTS_DIR = path.join(SESSIONS_DIR, '_attachments');
+const ATTACHMENT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
+const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
+const MAX_MESSAGE_ATTACHMENTS = 4;
+const IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
+const NOTIFY_CONFIG_PATH = path.join(CONFIG_DIR, 'notify.json');
+const AUTH_CONFIG_PATH = path.join(CONFIG_DIR, 'auth.json');
+const MODEL_CONFIG_PATH = path.join(CONFIG_DIR, 'model.json');
+const CODEX_CONFIG_PATH = path.join(CONFIG_DIR, 'codex.json');
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
fs.mkdirSync(LOGS_DIR, { recursive: true });
-fs.mkdirSync(path.dirname(NOTIFY_CONFIG_PATH), { recursive: true });
+fs.mkdirSync(CONFIG_DIR, { recursive: true });
+fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
// === Process Lifecycle Logger ===
const LOG_FILE = path.join(LOGS_DIR, 'process.log');
@@ -247,6 +258,8 @@ let MODEL_MAP = {
haiku: 'claude-haiku-4-5-20251001',
};
+const VALID_AGENTS = new Set(['claude', 'codex']);
+
// === Model Config ===
const DEFAULT_MODEL_CONFIG = {
mode: 'local', // 'local' | 'custom'
@@ -254,6 +267,14 @@ const DEFAULT_MODEL_CONFIG = {
activeTemplate: '', // name of active template (for 'custom' mode)
};
+const DEFAULT_CODEX_CONFIG = {
+ mode: 'local',
+ activeProfile: '',
+ profiles: [],
+ enableSearch: false,
+ supportsSearch: false,
+};
+
function loadModelConfig() {
try {
if (fs.existsSync(MODEL_CONFIG_PATH)) {
@@ -267,6 +288,56 @@ function saveModelConfig(config) {
fs.writeFileSync(MODEL_CONFIG_PATH, JSON.stringify(config, null, 2));
}
+function loadCodexConfig() {
+ try {
+ if (fs.existsSync(CODEX_CONFIG_PATH)) {
+ const raw = JSON.parse(fs.readFileSync(CODEX_CONFIG_PATH, 'utf8'));
+ return {
+ mode: raw.mode === 'custom' ? 'custom' : 'local',
+ activeProfile: raw.activeProfile || '',
+ profiles: Array.isArray(raw.profiles) ? raw.profiles.map((profile) => ({
+ name: String(profile?.name || '').trim(),
+ apiKey: String(profile?.apiKey || ''),
+ apiBase: String(profile?.apiBase || '').trim(),
+ })).filter((profile) => profile.name) : [],
+ enableSearch: false,
+ supportsSearch: false,
+ storedEnableSearch: !!raw.enableSearch,
+ };
+ }
+ } catch {}
+ return JSON.parse(JSON.stringify(DEFAULT_CODEX_CONFIG));
+}
+
+function saveCodexConfig(config) {
+ fs.writeFileSync(CODEX_CONFIG_PATH, JSON.stringify({
+ mode: config.mode === 'custom' ? 'custom' : 'local',
+ activeProfile: config.activeProfile || '',
+ profiles: Array.isArray(config.profiles) ? config.profiles.map((profile) => ({
+ name: String(profile?.name || '').trim(),
+ apiKey: String(profile?.apiKey || ''),
+ apiBase: String(profile?.apiBase || '').trim(),
+ })).filter((profile) => profile.name) : [],
+ enableSearch: false,
+ }, null, 2));
+}
+
+function getCodexConfigMasked() {
+ const config = loadCodexConfig();
+ return {
+ mode: config.mode === 'custom' ? 'custom' : 'local',
+ activeProfile: config.activeProfile || '',
+ profiles: (config.profiles || []).map((profile) => ({
+ name: profile.name,
+ apiKey: maskSecret(profile.apiKey),
+ apiBase: profile.apiBase || '',
+ })),
+ enableSearch: false,
+ supportsSearch: false,
+ storedEnableSearch: !!config.storedEnableSearch,
+ };
+}
+
function maskSecret(str) {
if (!str || str.length <= 8) return str ? '****' : '';
return str.slice(0, 4) + '****' + str.slice(-4);
@@ -289,6 +360,46 @@ function getModelConfigMasked() {
};
}
+const CODEX_RUNTIME_HOME = path.join(CONFIG_DIR, 'codex-runtime-home');
+
+function tomlString(value) {
+ return JSON.stringify(String(value || ''));
+}
+
+function prepareCodexCustomRuntime(config) {
+ if (!config || config.mode !== 'custom') return { mode: 'local' };
+ const profiles = Array.isArray(config.profiles) ? config.profiles : [];
+ const activeProfile = profiles.find((profile) => profile.name === config.activeProfile) || null;
+ if (!activeProfile) {
+ return { error: 'Codex 自定义配置缺少已激活的 profile。请先在设置中创建并激活一个 API 配置。' };
+ }
+ if (!activeProfile.apiKey || !activeProfile.apiBase) {
+ return { error: `Codex profile「${activeProfile.name}」缺少 API Key 或 API Base URL。` };
+ }
+
+ fs.mkdirSync(CODEX_RUNTIME_HOME, { recursive: true });
+ const configToml = [
+ 'preferred_auth_method = "apikey"',
+ 'model_provider = "openai_compat"',
+ '',
+ '[model_providers.openai_compat]',
+ `name = ${tomlString(activeProfile.name || 'OpenAI Compat')}`,
+ `base_url = ${tomlString(activeProfile.apiBase)}`,
+ 'env_key = "OPENAI_API_KEY"',
+ 'wire_api = "responses"',
+ '',
+ ].join('\n');
+ fs.writeFileSync(path.join(CODEX_RUNTIME_HOME, 'config.toml'), configToml);
+
+ return {
+ mode: 'custom',
+ homeDir: CODEX_RUNTIME_HOME,
+ apiKey: activeProfile.apiKey,
+ apiBase: activeProfile.apiBase,
+ profileName: activeProfile.name,
+ };
+}
+
// Read ~/.claude.json for model name overrides
function loadClaudeJsonModelMap() {
try {
@@ -389,15 +500,205 @@ function runDir(sessionId) {
return path.join(SESSIONS_DIR, `${sanitizeId(sessionId)}-run`);
}
+function attachmentDataPath(id, ext = '') {
+ return path.join(ATTACHMENTS_DIR, `${sanitizeId(id)}${ext}`);
+}
+
+function attachmentMetaPath(id) {
+ return path.join(ATTACHMENTS_DIR, `${sanitizeId(id)}.json`);
+}
+
+function safeFilename(name) {
+ return String(name || 'image')
+ .replace(/[\/\\?%*:|"<>]/g, '-')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, 120) || 'image';
+}
+
+function extFromMime(mime) {
+ switch (mime) {
+ case 'image/png': return '.png';
+ case 'image/jpeg': return '.jpg';
+ case 'image/webp': return '.webp';
+ case 'image/gif': return '.gif';
+ default: return '';
+ }
+}
+
+function loadAttachmentMeta(id) {
+ try {
+ return JSON.parse(fs.readFileSync(attachmentMetaPath(id), 'utf8'));
+ } catch {
+ return null;
+ }
+}
+
+function saveAttachmentMeta(meta) {
+ fs.writeFileSync(attachmentMetaPath(meta.id), JSON.stringify(meta, null, 2));
+}
+
+function removeAttachmentById(id) {
+ const meta = loadAttachmentMeta(id);
+ const paths = new Set([attachmentMetaPath(id)]);
+ if (meta?.path) paths.add(meta.path);
+ for (const filePath of paths) {
+ try {
+ if (filePath && fs.existsSync(filePath)) fs.unlinkSync(filePath);
+ } catch {}
+ }
+}
+
+function currentAttachmentState(meta) {
+ if (!meta) return 'missing';
+ const expiresAtMs = new Date(meta.expiresAt || 0).getTime();
+ if (expiresAtMs && Date.now() > expiresAtMs) return 'expired';
+ if (!meta.path || !fs.existsSync(meta.path)) return 'missing';
+ return 'available';
+}
+
+function normalizeMessageAttachments(attachments) {
+ if (!Array.isArray(attachments) || attachments.length === 0) return [];
+ const normalized = [];
+ for (const attachment of attachments) {
+ const id = sanitizeId(attachment?.id || '');
+ if (!id) continue;
+ const meta = loadAttachmentMeta(id);
+ const state = currentAttachmentState(meta);
+ if (state === 'expired') removeAttachmentById(id);
+ normalized.push({
+ id,
+ kind: 'image',
+ filename: meta?.filename || attachment?.filename || 'image',
+ mime: meta?.mime || attachment?.mime || 'image/png',
+ size: meta?.size || attachment?.size || 0,
+ createdAt: meta?.createdAt || attachment?.createdAt || null,
+ expiresAt: meta?.expiresAt || attachment?.expiresAt || null,
+ storageState: state === 'available' ? 'available' : 'expired',
+ });
+ }
+ return normalized;
+}
+
+function resolveMessageAttachments(attachments) {
+ const resolved = [];
+ for (const attachment of normalizeMessageAttachments(attachments)) {
+ if (attachment.storageState !== 'available') continue;
+ const meta = loadAttachmentMeta(attachment.id);
+ if (!meta?.path || !fs.existsSync(meta.path)) continue;
+ resolved.push({
+ ...attachment,
+ path: meta.path,
+ });
+ }
+ return resolved;
+}
+
+function cleanupExpiredAttachments() {
+ try {
+ const files = fs.readdirSync(ATTACHMENTS_DIR).filter((name) => name.endsWith('.json'));
+ for (const file of files) {
+ const id = file.replace(/\.json$/, '');
+ const meta = loadAttachmentMeta(id);
+ if (!meta || currentAttachmentState(meta) === 'expired') {
+ removeAttachmentById(id);
+ }
+ }
+ } catch {}
+}
+
+function collectSessionAttachmentIds(session) {
+ const ids = new Set();
+ for (const message of Array.isArray(session?.messages) ? session.messages : []) {
+ for (const attachment of Array.isArray(message?.attachments) ? message.attachments : []) {
+ const id = sanitizeId(attachment?.id || '');
+ if (id) ids.add(id);
+ }
+ }
+ return Array.from(ids);
+}
+
+function extractBearerToken(req) {
+ const authHeader = String(req.headers.authorization || '');
+ const m = authHeader.match(/^Bearer\s+(.+)$/i);
+ return m ? m[1] : '';
+}
+
+function jsonResponse(res, statusCode, payload) {
+ res.writeHead(statusCode, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-cache',
+ });
+ res.end(JSON.stringify(payload));
+}
+
+const INITIAL_HISTORY_COUNT = 12;
+const HISTORY_CHUNK_SIZE = 24;
+
+function normalizeAgent(agent) {
+ return VALID_AGENTS.has(agent) ? agent : 'claude';
+}
+
+function normalizeSession(session) {
+ if (!session || typeof session !== 'object') return session;
+ session.agent = normalizeAgent(session.agent);
+ if (!Object.prototype.hasOwnProperty.call(session, 'claudeSessionId')) session.claudeSessionId = null;
+ if (!Object.prototype.hasOwnProperty.call(session, 'codexThreadId')) session.codexThreadId = null;
+ if (!Object.prototype.hasOwnProperty.call(session, 'totalCost')) session.totalCost = 0;
+ if (!Object.prototype.hasOwnProperty.call(session, 'totalUsage') || !session.totalUsage) {
+ session.totalUsage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
+ }
+ if (!Object.prototype.hasOwnProperty.call(session, 'messages')) session.messages = [];
+ if (Array.isArray(session.messages)) {
+ session.messages = session.messages.map((message) => {
+ if (!message || typeof message !== 'object') return message;
+ if (message.attachments) {
+ return { ...message, attachments: normalizeMessageAttachments(message.attachments) };
+ }
+ return message;
+ });
+ }
+ return session;
+}
+
+function getSessionAgent(session) {
+ return normalizeAgent(session?.agent);
+}
+
+function isClaudeSession(session) {
+ return getSessionAgent(session) === 'claude';
+}
+
+function getRuntimeSessionId(session) {
+ if (!session) return null;
+ return getSessionAgent(session) === 'codex'
+ ? (session.codexThreadId || null)
+ : (session.claudeSessionId || null);
+}
+
+function setRuntimeSessionId(session, runtimeId) {
+ if (!session) return;
+ if (getSessionAgent(session) === 'codex') {
+ session.codexThreadId = runtimeId || null;
+ } else {
+ session.claudeSessionId = runtimeId || null;
+ }
+}
+
+function clearRuntimeSessionId(session) {
+ setRuntimeSessionId(session, null);
+}
+
function loadSession(id) {
try {
- return JSON.parse(fs.readFileSync(sessionPath(id), 'utf8'));
+ return normalizeSession(JSON.parse(fs.readFileSync(sessionPath(id), 'utf8')));
} catch {
return null;
}
}
function saveSession(session) {
+ normalizeSession(session);
fs.writeFileSync(sessionPath(session.id), JSON.stringify(session, null, 2));
}
@@ -407,6 +708,26 @@ function modelShortName(fullModel) {
return entry ? entry[0] : null;
}
+function sessionModelLabel(session) {
+ if (!session?.model) return null;
+ return isClaudeSession(session) ? (modelShortName(session.model) || session.model) : session.model;
+}
+
+function splitHistoryMessages(messages) {
+ const list = Array.isArray(messages) ? messages : [];
+ if (list.length <= INITIAL_HISTORY_COUNT) {
+ return { recentMessages: list, olderChunks: [] };
+ }
+ const recentMessages = list.slice(-INITIAL_HISTORY_COUNT);
+ const older = list.slice(0, -INITIAL_HISTORY_COUNT);
+ const olderChunks = [];
+ for (let end = older.length; end > 0; end -= HISTORY_CHUNK_SIZE) {
+ const start = Math.max(0, end - HISTORY_CHUNK_SIZE);
+ olderChunks.push(older.slice(start, end));
+ }
+ return { recentMessages, olderChunks };
+}
+
const IS_WIN = process.platform === 'win32';
function isProcessRunning(pid) {
@@ -443,8 +764,15 @@ function sendSessionList(ws) {
const sessions = [];
for (const f of files) {
try {
- const s = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'));
- sessions.push({ id: s.id, title: s.title || 'Untitled', updated: s.updated, hasUnread: !!s.hasUnread });
+ const s = normalizeSession(JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8')));
+ sessions.push({
+ id: s.id,
+ title: s.title || 'Untitled',
+ updated: s.updated,
+ hasUnread: !!s.hasUnread,
+ agent: getSessionAgent(s),
+ isRunning: activeProcesses.has(s.id),
+ });
} catch {}
}
sessions.sort((a, b) => new Date(b.updated) - new Date(a.updated));
@@ -508,6 +836,65 @@ class FileTailer {
// === Process Lifecycle ===
+function firstMeaningfulLine(text) {
+ return String(text || '')
+ .split('\n')
+ .map((line) => line.trim())
+ .find(Boolean) || '';
+}
+
+function condenseRuntimeError(raw) {
+ const text = String(raw || '').trim();
+ if (!text) return '';
+ const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
+ const usageIndex = lines.findIndex((line) => /^Usage:/i.test(line));
+ if (usageIndex >= 0) return lines.slice(0, usageIndex).join(' ');
+ return lines.slice(0, 3).join(' ');
+}
+
+function formatRuntimeError(agent, raw, context = {}) {
+ const condensed = condenseRuntimeError(raw);
+ const exitInfo = typeof context.exitCode === 'number' ? `(退出码 ${context.exitCode})` : '';
+ if (!condensed) {
+ return agent === 'codex'
+ ? `Codex 任务异常结束${exitInfo},但 CLI 没有返回更多错误信息。`
+ : `Claude 任务异常结束${exitInfo},但 CLI 没有返回更多错误信息。`;
+ }
+
+ if (agent === 'codex') {
+ if (/ENOENT|not found|No such file/i.test(condensed)) {
+ return '找不到 Codex CLI。请检查 Codex 设置里的 CLI 路径,或确认系统 PATH 中可直接运行 `codex`。';
+ }
+ if (/unexpected argument|unexpected option|Usage:\s*codex/i.test(raw || '')) {
+ return `Codex CLI 参数不兼容:${firstMeaningfulLine(condensed)}。建议检查当前 CLI 版本与 cc-web 的参数约定是否匹配。`;
+ }
+ if (/permission denied|EACCES|EPERM/i.test(condensed)) {
+ return 'Codex CLI 启动失败:当前环境没有足够权限执行该命令或访问目标目录。';
+ }
+ if (/authentication|unauthorized|forbidden|login|api key|credential/i.test(condensed)) {
+ return 'Codex 鉴权失败。请确认本机 Codex CLI 已完成登录,且当前凭据仍然有效。';
+ }
+ if (/rate limit|quota|billing|credits/i.test(condensed)) {
+ return 'Codex 请求被额度或速率限制拦截。请检查账号配额、计费状态或稍后重试。';
+ }
+ if (/network|timed out|timeout|ECONNRESET|ENOTFOUND|TLS|certificate|fetch failed/i.test(condensed)) {
+ return 'Codex 运行时网络请求失败。请检查当前网络、代理或证书环境后重试。';
+ }
+ if (/sandbox|approval|read-only|bypass-approvals/i.test(condensed)) {
+ return `Codex 当前的审批或沙箱设置阻止了这次执行:${firstMeaningfulLine(condensed)}`;
+ }
+ return `Codex 任务失败${exitInfo}:${condensed}`;
+ }
+
+ if (/ENOENT|not found|No such file/i.test(condensed)) {
+ return '找不到 Claude CLI。请检查当前环境是否能直接运行 `claude`。';
+ }
+ if (/authentication|unauthorized|forbidden|api key|credential/i.test(condensed)) {
+ return 'Claude 鉴权失败。请确认本机 Claude CLI 已完成登录,且凭据仍然有效。';
+ }
+ return `Claude 任务失败${exitInfo}:${condensed}`;
+}
+
function handleProcessComplete(sessionId, exitCode, signal) {
const entry = activeProcesses.get(sessionId);
if (!entry) return;
@@ -531,11 +918,20 @@ function handleProcessComplete(sessionId, exitCode, signal) {
}
} catch {}
- requestTooLarge = /Request too large \(max 20MB\)/i.test(entry.fullText || '') || /Request too large \(max 20MB\)/i.test(stderrSnippet || '');
+ requestTooLarge = entry.agent === 'claude'
+ && (/Request too large \(max 20MB\)/i.test(entry.fullText || '') || /Request too large \(max 20MB\)/i.test(stderrSnippet || ''));
+ const rawCompletionError = entry.lastError || (
+ ((typeof exitCode === 'number' && exitCode !== 0) || (!!signal && signal !== 'SIGTERM'))
+ ? (stderrSnippet || null)
+ : null
+ );
+ const completionError = rawCompletionError ? formatRuntimeError(entry.agent || 'claude', rawCompletionError, { exitCode, signal }) : null;
+ if (!entry.lastError && rawCompletionError) entry.lastError = rawCompletionError;
plog(exitCode === 0 || exitCode === null ? 'INFO' : 'WARN', 'process_complete', {
sessionId: sessionId.slice(0, 8),
pid: entry.pid,
+ agent: entry.agent || 'claude',
exitCode,
signal,
wsConnected,
@@ -544,6 +940,8 @@ function handleProcessComplete(sessionId, exitCode, signal) {
responseLen: (entry.fullText || '').length,
toolCallCount: (entry.toolCalls || []).length,
cost: entry.lastCost,
+ usage: entry.lastUsage || null,
+ error: rawCompletionError,
stderr: stderrSnippet || null,
requestTooLarge,
});
@@ -581,6 +979,10 @@ function handleProcessComplete(sessionId, exitCode, signal) {
let shouldReturnForFollowup = false;
+ activeProcesses.delete(sessionId);
+ cleanRunDir(sessionId);
+ pendingSlashCommands.delete(sessionId);
+
// Notify client
if (entry.ws) {
if (pendingSlash?.kind === 'compact') {
@@ -608,6 +1010,11 @@ function handleProcessComplete(sessionId, exitCode, signal) {
shouldReturnForFollowup = true;
}
+ if (completionError && !entry.errorSent) {
+ entry.errorSent = true;
+ wsSend(entry.ws, { type: 'error', message: completionError });
+ }
+
wsSend(entry.ws, { type: 'done', sessionId, costUsd: entry.lastCost || null });
sendSessionList(entry.ws);
} else {
@@ -634,10 +1041,6 @@ function handleProcessComplete(sessionId, exitCode, signal) {
);
}
- activeProcesses.delete(sessionId);
- cleanRunDir(sessionId);
- pendingSlashCommands.delete(sessionId);
-
if (!shouldReturnForFollowup && !requestTooLarge && pendingRetry && pendingRetry.text === (entry.fullText || '').trim()) {
pendingCompactRetries.delete(sessionId);
}
@@ -674,6 +1077,9 @@ setInterval(() => {
}
}, 2000);
+cleanupExpiredAttachments();
+setInterval(cleanupExpiredAttachments, 6 * 60 * 60 * 1000);
+
// Recover processes that were running before server restart
function recoverProcesses() {
try {
@@ -685,6 +1091,8 @@ function recoverProcesses() {
const dir = path.join(SESSIONS_DIR, dirName);
const pidPath = path.join(dir, 'pid');
const outputPath = path.join(dir, 'output.jsonl');
+ const session = loadSession(sessionId);
+ const agent = getSessionAgent(session);
if (!fs.existsSync(pidPath)) {
try { fs.rmSync(dir, { recursive: true }); } catch {}
@@ -695,15 +1103,15 @@ function recoverProcesses() {
if (isProcessRunning(pid)) {
console.log(`[recovery] Re-attaching to session ${sessionId} (PID ${pid})`);
- plog('INFO', 'recovery_alive', { sessionId: sessionId.slice(0, 8), pid });
- const entry = { pid, ws: null, fullText: '', toolCalls: [], lastCost: null, tailer: null };
+ plog('INFO', 'recovery_alive', { sessionId: sessionId.slice(0, 8), pid, agent });
+ const entry = { pid, ws: null, agent, fullText: '', toolCalls: [], lastCost: null, lastUsage: null, lastError: null, errorSent: false, tailer: null };
activeProcesses.set(sessionId, entry);
if (fs.existsSync(outputPath)) {
entry.tailer = new FileTailer(outputPath, (line) => {
try {
const event = JSON.parse(line);
- processClaudeEvent(entry, event, sessionId);
+ processRuntimeEvent(entry, event, sessionId);
} catch {}
});
entry.tailer.start();
@@ -711,18 +1119,17 @@ function recoverProcesses() {
} else {
// Process finished while server was down — read all output and save
console.log(`[recovery] Processing completed output for session ${sessionId}`);
- plog('INFO', 'recovery_dead', { sessionId: sessionId.slice(0, 8), pid });
+ plog('INFO', 'recovery_dead', { sessionId: sessionId.slice(0, 8), pid, agent });
if (fs.existsSync(outputPath)) {
- const tempEntry = { pid: 0, ws: null, fullText: '', toolCalls: [], lastCost: null, tailer: null };
+ const tempEntry = { pid: 0, ws: null, agent, fullText: '', toolCalls: [], lastCost: null, lastUsage: null, lastError: null, errorSent: false, tailer: null };
const content = fs.readFileSync(outputPath, 'utf8');
for (const line of content.split('\n')) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
- processClaudeEvent(tempEntry, event, sessionId);
+ processRuntimeEvent(tempEntry, event, sessionId);
} catch {}
}
- const session = loadSession(sessionId);
if (session && tempEntry.fullText) {
session.messages.push({
role: 'assistant',
@@ -745,6 +1152,94 @@ function recoverProcesses() {
// === HTTP Static File Server ===
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
+
+ if (req.method === 'POST' && url.pathname === '/api/attachments') {
+ const token = extractBearerToken(req);
+ if (!token || !activeTokens.has(token)) {
+ return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
+ }
+ const mime = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
+ const rawName = decodeURIComponent(String(req.headers['x-filename'] || 'image'));
+ const filename = safeFilename(rawName);
+ if (!IMAGE_MIME_TYPES.has(mime)) {
+ return jsonResponse(res, 400, { ok: false, message: '仅支持 PNG/JPG/WEBP/GIF 图片' });
+ }
+
+ const chunks = [];
+ let total = 0;
+ let aborted = false;
+ req.on('data', (chunk) => {
+ total += chunk.length;
+ if (total > MAX_ATTACHMENT_SIZE) {
+ aborted = true;
+ req.destroy();
+ return;
+ }
+ chunks.push(chunk);
+ });
+ req.on('end', () => {
+ if (aborted) {
+ return jsonResponse(res, 413, { ok: false, message: '图片大小不能超过 10MB' });
+ }
+ const buffer = Buffer.concat(chunks);
+ if (buffer.length === 0) {
+ return jsonResponse(res, 400, { ok: false, message: '图片内容为空' });
+ }
+ const id = crypto.randomUUID();
+ const ext = extFromMime(mime) || path.extname(filename) || '';
+ const dataPath = attachmentDataPath(id, ext);
+ const now = new Date();
+ const meta = {
+ id,
+ kind: 'image',
+ filename,
+ mime,
+ size: buffer.length,
+ createdAt: now.toISOString(),
+ expiresAt: new Date(now.getTime() + ATTACHMENT_TTL_MS).toISOString(),
+ path: dataPath,
+ };
+ try {
+ fs.writeFileSync(dataPath, buffer);
+ saveAttachmentMeta(meta);
+ return jsonResponse(res, 200, {
+ ok: true,
+ attachment: {
+ id,
+ kind: 'image',
+ filename,
+ mime,
+ size: buffer.length,
+ createdAt: meta.createdAt,
+ expiresAt: meta.expiresAt,
+ storageState: 'available',
+ },
+ });
+ } catch (err) {
+ try { if (fs.existsSync(dataPath)) fs.unlinkSync(dataPath); } catch {}
+ try { if (fs.existsSync(attachmentMetaPath(id))) fs.unlinkSync(attachmentMetaPath(id)); } catch {}
+ return jsonResponse(res, 500, { ok: false, message: `保存附件失败: ${err.message}` });
+ }
+ });
+ req.on('error', () => {
+ if (!res.headersSent) jsonResponse(res, 500, { ok: false, message: '上传过程中断' });
+ });
+ return;
+ }
+
+ if (req.method === 'DELETE' && url.pathname.startsWith('/api/attachments/')) {
+ const token = extractBearerToken(req);
+ if (!token || !activeTokens.has(token)) {
+ return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
+ }
+ const id = sanitizeId(url.pathname.split('/').pop() || '');
+ if (!id) {
+ return jsonResponse(res, 400, { ok: false, message: '缺少附件 ID' });
+ }
+ removeAttachmentById(id);
+ return jsonResponse(res, 200, { ok: true });
+ }
+
let filePath = path.join(PUBLIC_DIR, url.pathname === '/' ? 'index.html' : url.pathname);
filePath = path.resolve(filePath);
@@ -805,7 +1300,7 @@ wss.on('connection', (ws) => {
switch (msg.type) {
case 'message':
if (msg.text && msg.text.trim().startsWith('/')) {
- handleSlashCommand(ws, msg.text.trim(), msg.sessionId);
+ handleSlashCommand(ws, msg.text.trim(), msg.sessionId, msg.agent);
} else {
handleMessage(ws, msg);
}
@@ -831,6 +1326,9 @@ wss.on('connection', (ws) => {
case 'list_sessions':
sendSessionList(ws);
break;
+ case 'detach_view':
+ handleDetachView(ws);
+ break;
case 'get_notify_config':
wsSend(ws, { type: 'notify_config', config: getNotifyConfigMasked() });
break;
@@ -849,6 +1347,12 @@ wss.on('connection', (ws) => {
case 'save_model_config':
handleSaveModelConfig(ws, msg.config);
break;
+ case 'get_codex_config':
+ wsSend(ws, { type: 'codex_config', config: getCodexConfigMasked() });
+ break;
+ case 'save_codex_config':
+ handleSaveCodexConfig(ws, msg.config);
+ break;
case 'fetch_models':
handleFetchModels(ws, msg);
break;
@@ -861,6 +1365,12 @@ wss.on('connection', (ws) => {
case 'import_native_session':
handleImportNativeSession(ws, msg);
break;
+ case 'list_codex_sessions':
+ handleListCodexSessions(ws);
+ break;
+ case 'import_codex_session':
+ handleImportCodexSession(ws, msg);
+ break;
case 'list_cwd_suggestions':
handleListCwdSuggestions(ws);
break;
@@ -988,6 +1498,54 @@ function handleSaveModelConfig(ws, newConfig) {
wsSend(ws, { type: 'system_message', message: '模型配置已保存' });
}
+function handleSaveCodexConfig(ws, newConfig) {
+ if (!newConfig || typeof newConfig !== 'object') {
+ return wsSend(ws, { type: 'error', message: '无效的 Codex 配置' });
+ }
+ const current = loadCodexConfig();
+ const newProfiles = Array.isArray(newConfig.profiles) ? newConfig.profiles : [];
+ const oldProfiles = Array.isArray(current.profiles) ? current.profiles : [];
+ const mergedProfiles = [];
+ for (const profile of newProfiles) {
+ const name = String(profile?.name || '').trim();
+ if (!name) continue;
+ const old = oldProfiles.find((item) => item.name === name);
+ const rawApiKey = String(profile?.apiKey || '');
+ mergedProfiles.push({
+ name,
+ apiKey: rawApiKey && !rawApiKey.includes('****') ? rawApiKey : (old?.apiKey || ''),
+ apiBase: String(profile?.apiBase || '').trim(),
+ });
+ }
+ const requestedSearch = !!newConfig.enableSearch;
+ const merged = {
+ mode: newConfig.mode === 'custom' ? 'custom' : 'local',
+ activeProfile: String(newConfig.activeProfile || '').trim(),
+ profiles: mergedProfiles,
+ enableSearch: false,
+ supportsSearch: false,
+ storedEnableSearch: requestedSearch,
+ };
+ if (merged.mode === 'custom' && merged.profiles.length > 0 && !merged.profiles.some((profile) => profile.name === merged.activeProfile)) {
+ merged.activeProfile = merged.profiles[0].name;
+ }
+ saveCodexConfig(merged);
+ plog('INFO', 'codex_config_saved', {
+ mode: merged.mode,
+ activeProfile: merged.activeProfile || null,
+ profileCount: merged.profiles.length,
+ enableSearchRequested: requestedSearch,
+ enableSearchEffective: false,
+ });
+ wsSend(ws, { type: 'codex_config', config: getCodexConfigMasked() });
+ wsSend(ws, {
+ type: 'system_message',
+ message: requestedSearch
+ ? 'Codex 配置已保存。当前 cc-web 的 Codex exec 路径暂未接入 Web Search,已自动忽略该开关。'
+ : 'Codex 配置已保存',
+ });
+}
+
// === Fetch Upstream Models ===
function handleFetchModels(ws, msg) {
const { apiBase, apiKey, modelsEndpoint } = msg;
@@ -1052,10 +1610,11 @@ function handleFetchModels(ws, msg) {
}
// === Slash Command Handler ===
-function handleSlashCommand(ws, text, sessionId) {
+function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
const parts = text.split(/\s+/);
const cmd = parts[0].toLowerCase();
let session = sessionId ? loadSession(sessionId) : null;
+ const agent = session ? getSessionAgent(session) : normalizeAgent(fallbackAgent);
switch (cmd) {
case '/clear': {
@@ -1068,10 +1627,21 @@ function handleSlashCommand(ws, text, sessionId) {
cleanRunDir(sessionId);
}
session.messages = [];
- session.claudeSessionId = null;
+ clearRuntimeSessionId(session);
session.updated = new Date().toISOString();
saveSession(session);
- wsSend(ws, { type: 'session_info', sessionId: session.id, messages: [], title: session.title });
+ wsSend(ws, {
+ type: 'session_info',
+ sessionId: session.id,
+ messages: [],
+ title: session.title,
+ mode: session.permissionMode || 'yolo',
+ model: sessionModelLabel(session),
+ agent: getSessionAgent(session),
+ cwd: session.cwd || null,
+ totalCost: session.totalCost || 0,
+ totalUsage: session.totalUsage || null,
+ });
}
wsSend(ws, { type: 'system_message', message: '会话已清除,上下文已重置。' });
break;
@@ -1079,7 +1649,20 @@ function handleSlashCommand(ws, text, sessionId) {
case '/model': {
const modelInput = parts[1];
- if (!modelInput) {
+ if (agent === 'codex') {
+ if (!modelInput) {
+ const current = session?.model || '配置默认模型';
+ wsSend(ws, { type: 'system_message', message: `当前 Codex 模型: ${current}\n用法: /model <模型名>` });
+ } else {
+ if (session) {
+ session.model = modelInput;
+ session.updated = new Date().toISOString();
+ saveSession(session);
+ }
+ wsSend(ws, { type: 'model_changed', model: modelInput });
+ wsSend(ws, { type: 'system_message', message: `Codex 模型已切换为: ${modelInput}` });
+ }
+ } else if (!modelInput) {
const current = session?.model ? modelShortName(session.model) || session.model : 'opus (默认)';
wsSend(ws, { type: 'system_message', message: `当前模型: ${current}\n可选: opus, sonnet, haiku` });
} else {
@@ -1101,12 +1684,24 @@ function handleSlashCommand(ws, text, sessionId) {
}
case '/cost': {
- const cost = session?.totalCost || 0;
- wsSend(ws, { type: 'system_message', message: `当前会话累计费用: $${cost.toFixed(4)}` });
+ if (agent === 'codex') {
+ const usage = session?.totalUsage || { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
+ wsSend(ws, {
+ type: 'system_message',
+ message: `当前会话累计 Token: 输入 ${usage.inputTokens},缓存 ${usage.cachedInputTokens},输出 ${usage.outputTokens}`,
+ });
+ } else {
+ const cost = session?.totalCost || 0;
+ wsSend(ws, { type: 'system_message', message: `当前会话累计费用: $${cost.toFixed(4)}` });
+ }
break;
}
case '/compact': {
+ if (agent !== 'claude') {
+ wsSend(ws, { type: 'system_message', message: 'Codex 会话暂不支持 /compact。' });
+ break;
+ }
if (!sessionId || !session) {
wsSend(ws, { type: 'system_message', message: '当前没有可压缩的会话。请先进入一个已进行过对话的会话后再执行 /compact。' });
break;
@@ -1137,7 +1732,7 @@ function handleSlashCommand(ws, text, sessionId) {
const mode = modeInput.toLowerCase();
if (session) {
session.permissionMode = mode;
- session.claudeSessionId = null;
+ clearRuntimeSessionId(session);
session.updated = new Date().toISOString();
saveSession(session);
}
@@ -1150,15 +1745,16 @@ function handleSlashCommand(ws, text, sessionId) {
}
case '/help': {
+ const base = '可用指令:\n' +
+ '/clear — 清除当前会话(含上下文)\n' +
+ '/mode [模式] — 查看/切换权限模式(default, plan, yolo)\n' +
+ '/cost — 查看当前会话累计统计\n' +
+ '/help — 显示本帮助';
wsSend(ws, {
type: 'system_message',
- message: '可用指令:\n' +
- '/clear — 清除当前会话(含上下文)\n' +
- '/model [名称] — 查看/切换模型(opus, sonnet, haiku)\n' +
- '/mode [模式] — 查看/切换权限模式(default, plan, yolo)\n' +
- '/cost — 查看当前会话累计费用\n' +
- '/compact — 执行 Claude 原生上下文压缩(保留压缩计划并可自动续跑)\n' +
- '/help — 显示本帮助',
+ message: agent === 'codex'
+ ? base + '\n/model [名称] — 查看/切换 Codex 模型(自由输入)'
+ : base + '\n/model [名称] — 查看/切换模型(opus, sonnet, haiku)\n/compact — 执行 Claude 原生上下文压缩(保留压缩计划并可自动续跑)',
});
break;
}
@@ -1171,22 +1767,43 @@ function handleSlashCommand(ws, text, sessionId) {
// === Session Handlers ===
function handleNewSession(ws, msg) {
const cwd = (msg && msg.cwd) ? String(msg.cwd) : null;
+ const agent = normalizeAgent(msg?.agent);
+ const requestedMode = ['default', 'plan', 'yolo'].includes(msg?.mode) ? msg.mode : 'yolo';
+ const resolvedCwd = cwd || (agent === 'claude' ? (process.env.HOME || process.env.USERPROFILE || process.cwd()) : null);
const id = crypto.randomUUID();
const session = {
id,
title: 'New Chat',
created: new Date().toISOString(),
updated: new Date().toISOString(),
+ agent,
claudeSessionId: null,
+ codexThreadId: null,
model: null,
- permissionMode: 'yolo',
+ permissionMode: requestedMode,
totalCost: 0,
+ totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
messages: [],
- cwd: cwd,
+ cwd: resolvedCwd,
};
saveSession(session);
wsSessionMap.set(ws, id);
- wsSend(ws, { type: 'session_info', sessionId: id, messages: [], title: session.title, mode: session.permissionMode, model: null, cwd: session.cwd });
+ wsSend(ws, {
+ type: 'session_info',
+ sessionId: id,
+ messages: [],
+ title: session.title,
+ mode: session.permissionMode,
+ model: sessionModelLabel(session),
+ agent,
+ cwd: session.cwd,
+ totalCost: 0,
+ totalUsage: session.totalUsage,
+ updated: session.updated,
+ hasUnread: false,
+ historyPending: false,
+ isRunning: false,
+ });
sendSessionList(ws);
}
@@ -1195,6 +1812,16 @@ function handleLoadSession(ws, sessionId) {
if (!session) {
return wsSend(ws, { type: 'error', message: 'Session not found' });
}
+ if (getSessionAgent(session) === 'claude' && !session.cwd && session.claudeSessionId) {
+ const localMeta = resolveClaudeSessionLocalMeta(session.claudeSessionId);
+ if (localMeta?.cwd) {
+ session.cwd = localMeta.cwd;
+ if (!session.importedFrom && localMeta.projectDir) session.importedFrom = localMeta.projectDir;
+ saveSession(session);
+ }
+ }
+ const { recentMessages, olderChunks } = splitHistoryMessages(session.messages);
+ const effectiveCwd = session.cwd || activeProcesses.get(sessionId)?.cwd || null;
// Detach ws from any previous session's process
for (const [, entry] of activeProcesses) {
@@ -1213,14 +1840,33 @@ function handleLoadSession(ws, sessionId) {
wsSend(ws, {
type: 'session_info',
sessionId: session.id,
- messages: session.messages,
+ messages: recentMessages,
title: session.title,
mode: session.permissionMode || 'yolo',
- model: modelShortName(session.model),
+ model: sessionModelLabel(session),
+ agent: getSessionAgent(session),
hasUnread: hadUnread,
- cwd: session.cwd || null,
+ cwd: effectiveCwd,
+ totalCost: session.totalCost || 0,
+ totalUsage: session.totalUsage || null,
+ historyTotal: session.messages.length,
+ historyBuffered: recentMessages.length,
+ historyPending: olderChunks.length > 0,
+ updated: session.updated,
+ isRunning: activeProcesses.has(sessionId),
});
+ if (olderChunks.length > 0) {
+ olderChunks.forEach((chunk, index) => {
+ wsSend(ws, {
+ type: 'session_history_chunk',
+ sessionId: session.id,
+ messages: chunk,
+ remaining: Math.max(0, olderChunks.length - index - 1),
+ });
+ });
+ }
+
// Resume streaming if process is still active
if (activeProcesses.has(sessionId)) {
const entry = activeProcesses.get(sessionId);
@@ -1240,6 +1886,67 @@ function handleLoadSession(ws, sessionId) {
}
}
+function sqlQuote(value) {
+ return `'${String(value).replace(/'/g, "''")}'`;
+}
+
+function deleteClaudeLocalSession(claudeSessionId) {
+ if (!claudeSessionId) return;
+ const projectsDir = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'projects');
+ try {
+ for (const proj of fs.readdirSync(projectsDir)) {
+ const target = path.join(projectsDir, proj, `${claudeSessionId}.jsonl`);
+ if (fs.existsSync(target)) fs.unlinkSync(target);
+ }
+ } catch {}
+}
+
+function deleteCodexLocalSession(session) {
+ const threadId = session?.codexThreadId;
+ if (!threadId) return { removedFiles: 0, removedDbRows: false };
+
+ const rolloutPaths = new Set();
+ if (session.importedRolloutPath) rolloutPaths.add(path.resolve(session.importedRolloutPath));
+ try {
+ for (const filePath of getCodexRolloutFiles()) {
+ if (filePath.includes(threadId)) rolloutPaths.add(path.resolve(filePath));
+ }
+ } catch {}
+
+ let removedFiles = 0;
+ for (const filePath of rolloutPaths) {
+ try {
+ if (filePath.startsWith(CODEX_SESSIONS_DIR) && fs.existsSync(filePath)) {
+ fs.unlinkSync(filePath);
+ removedFiles++;
+ }
+ } catch {}
+ }
+
+ let removedDbRows = false;
+ try {
+ const sqlitePath = spawnSync('sqlite3', ['-version'], { stdio: 'ignore' });
+ if (sqlitePath.status === 0) {
+ const quotedThreadId = sqlQuote(threadId);
+ const stateSql = [
+ 'PRAGMA foreign_keys = ON;',
+ `DELETE FROM thread_dynamic_tools WHERE thread_id = ${quotedThreadId};`,
+ `DELETE FROM stage1_outputs WHERE thread_id = ${quotedThreadId};`,
+ `DELETE FROM logs WHERE thread_id = ${quotedThreadId};`,
+ `DELETE FROM threads WHERE id = ${quotedThreadId};`,
+ ].join(' ');
+ const stateResult = spawnSync('sqlite3', [CODEX_STATE_DB_PATH, stateSql], { stdio: 'ignore' });
+ if (stateResult.status === 0) removedDbRows = true;
+
+ if (fs.existsSync(CODEX_LOG_DB_PATH)) {
+ spawnSync('sqlite3', [CODEX_LOG_DB_PATH, `DELETE FROM logs WHERE thread_id = ${quotedThreadId};`], { stdio: 'ignore' });
+ }
+ }
+ } catch {}
+
+ return { removedFiles, removedDbRows };
+}
+
function handleDeleteSession(ws, sessionId) {
pendingSlashCommands.delete(sessionId);
pendingCompactRetries.delete(sessionId);
@@ -1253,22 +1960,22 @@ function handleDeleteSession(ws, sessionId) {
cleanRunDir(sessionId);
try {
const p = sessionPath(sessionId);
- // Read claudeSessionId before deleting the file
- let claudeSessionId = null;
- try {
- const session = loadSession(sessionId);
- claudeSessionId = session?.claudeSessionId || null;
- } catch {}
+ const session = loadSession(sessionId);
+ const sessionAgent = getSessionAgent(session);
+ for (const attachmentId of collectSessionAttachmentIds(session)) {
+ removeAttachmentById(attachmentId);
+ }
if (fs.existsSync(p)) fs.unlinkSync(p);
- // Sync-delete the corresponding Claude native session .jsonl
- if (claudeSessionId) {
- const projectsDir = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'projects');
- try {
- for (const proj of fs.readdirSync(projectsDir)) {
- const target = path.join(projectsDir, proj, `${claudeSessionId}.jsonl`);
- if (fs.existsSync(target)) fs.unlinkSync(target);
- }
- } catch {}
+ if (sessionAgent === 'codex') {
+ const result = deleteCodexLocalSession(session);
+ plog('INFO', 'codex_local_session_deleted', {
+ sessionId: sessionId.slice(0, 8),
+ threadId: session?.codexThreadId || null,
+ removedFiles: result.removedFiles,
+ removedDbRows: result.removedDbRows,
+ });
+ } else {
+ deleteClaudeLocalSession(session?.claudeSessionId || null);
}
sendSessionList(ws);
} catch {
@@ -1295,7 +2002,7 @@ function handleSetMode(ws, sessionId, mode) {
const session = loadSession(sessionId);
if (session) {
session.permissionMode = mode;
- session.claudeSessionId = null;
+ clearRuntimeSessionId(session);
session.updated = new Date().toISOString();
saveSession(session);
}
@@ -1316,6 +2023,16 @@ function handleDisconnect(ws, wsId) {
plog('INFO', 'ws_disconnect', { wsId, activeProcessesAffected: affectedSessions });
}
+function handleDetachView(ws) {
+ for (const [, entry] of activeProcesses) {
+ if (entry.ws === ws) {
+ entry.ws = null;
+ entry.wsDisconnectTime = new Date().toISOString();
+ }
+ }
+ wsSessionMap.delete(ws);
+}
+
function handleAbort(ws) {
const sessionId = wsSessionMap.get(ws);
if (!sessionId) return;
@@ -1330,34 +2047,65 @@ function handleAbort(ws) {
// handleProcessComplete will be triggered by the PID monitor
}
-// === Claude Message Handler ===
+// === Runtime Message Handler ===
function handleMessage(ws, msg, options = {}) {
const { text, sessionId, mode } = msg;
const { hideInHistory = false } = options;
- if (!text || !text.trim()) return;
+ const textValue = typeof text === 'string' ? text : '';
+ const attachments = Array.isArray(msg.attachments) ? msg.attachments.slice(0, MAX_MESSAGE_ATTACHMENTS) : [];
+ const normalizedText = textValue.trim();
+ const resolvedAttachments = resolveMessageAttachments(attachments);
+ if (attachments.length > 0 && resolvedAttachments.length === 0) {
+ return wsSend(ws, { type: 'error', message: '图片附件已过期或不可用,请重新上传后再发送。' });
+ }
+ if (!normalizedText && resolvedAttachments.length === 0) return;
- const normalizedText = text.trim();
+ const savedAttachments = resolvedAttachments.map((attachment) => ({
+ id: attachment.id,
+ kind: 'image',
+ filename: attachment.filename,
+ mime: attachment.mime,
+ size: attachment.size,
+ createdAt: attachment.createdAt,
+ expiresAt: attachment.expiresAt,
+ storageState: attachment.storageState,
+ }));
if (sessionId && activeProcesses.has(sessionId)) {
return wsSend(ws, { type: 'error', message: '正在处理中,请先点击停止按钮。' });
}
+ const derivedTitle = normalizedText
+ ? textValue.slice(0, 60).replace(/\n/g, ' ')
+ : `图片: ${savedAttachments[0]?.filename || 'image'}`;
+
let session;
if (sessionId) session = loadSession(sessionId);
if (!session) {
const id = crypto.randomUUID();
+ const agent = normalizeAgent(msg.agent);
+ const resolvedCwd = agent === 'claude' ? (process.env.HOME || process.env.USERPROFILE || process.cwd()) : null;
session = {
id,
- title: text.slice(0, 60).replace(/\n/g, ' '),
+ title: derivedTitle,
created: new Date().toISOString(),
updated: new Date().toISOString(),
+ agent,
claudeSessionId: null,
+ codexThreadId: null,
model: null,
permissionMode: mode || 'yolo',
totalCost: 0,
+ totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
messages: [],
+ cwd: resolvedCwd,
};
}
+ normalizeSession(session);
+
+ if (normalizedText.startsWith('/') && resolvedAttachments.length > 0) {
+ return wsSend(ws, { type: 'error', message: '命令消息暂不支持同时附带图片。请先发送图片说明,再单独使用 /model 或 /mode。' });
+ }
if (mode && ['default', 'plan', 'yolo'].includes(mode)) {
session.permissionMode = mode;
@@ -1368,11 +2116,16 @@ function handleMessage(ws, msg, options = {}) {
}
if (session.title === 'New Chat' || session.title === 'Untitled') {
- session.title = text.slice(0, 60).replace(/\n/g, ' ');
+ session.title = derivedTitle;
}
if (!hideInHistory) {
- session.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString() });
+ session.messages.push({
+ role: 'user',
+ content: textValue,
+ attachments: savedAttachments,
+ timestamp: new Date().toISOString(),
+ });
}
session.updated = new Date().toISOString();
saveSession(session);
@@ -1385,49 +2138,30 @@ function handleMessage(ws, msg, options = {}) {
wsSessionMap.set(ws, currentSessionId);
if (!sessionId) {
- wsSend(ws, { type: 'session_info', sessionId: currentSessionId, messages: session.messages, title: session.title, mode: session.permissionMode || 'yolo', model: modelShortName(session.model) });
+ wsSend(ws, {
+ type: 'session_info',
+ sessionId: currentSessionId,
+ messages: session.messages,
+ title: session.title,
+ mode: session.permissionMode || 'yolo',
+ model: sessionModelLabel(session),
+ agent: getSessionAgent(session),
+ cwd: session.cwd || null,
+ totalCost: session.totalCost || 0,
+ totalUsage: session.totalUsage || null,
+ updated: session.updated,
+ hasUnread: false,
+ historyPending: false,
+ isRunning: false,
+ });
}
sendSessionList(ws);
- // Build claude args
- const args = ['-p', '--output-format', 'stream-json', '--verbose'];
- const permMode = session.permissionMode || 'yolo';
- switch (permMode) {
- case 'yolo':
- args.push('--dangerously-skip-permissions');
- break;
- case 'plan':
- args.push('--permission-mode', 'plan');
- break;
- case 'default':
- break;
- }
- if (session.claudeSessionId) {
- args.push('--resume', session.claudeSessionId);
- }
- if (session.model) {
- // Only pass --model if it's a known valid model name in MODEL_MAP
- const validModels = new Set(Object.values(MODEL_MAP));
- if (validModels.has(session.model)) {
- args.push('--model', session.model);
- }
- }
-
- const env = { ...process.env };
- delete env.CLAUDECODE;
- delete env.CLAUDE_CODE;
- delete env.CC_WEB_PASSWORD;
- // Strip all ANTHROPIC_* from env — claude CLI reads ~/.claude/settings.json which takes priority
- for (const k of Object.keys(env)) {
- if (k.startsWith('ANTHROPIC_')) delete env[k];
- }
- // custom mode: patch ~/.claude/settings.json env section with template credentials
- {
- const modelCfg = loadModelConfig();
- if (modelCfg.mode === 'custom' && modelCfg.activeTemplate) {
- const tpl = (modelCfg.templates || []).find(t => t.name === modelCfg.activeTemplate);
- if (tpl) applyCustomTemplateToSettings(tpl);
- }
+ const spawnSpec = isClaudeSession(session)
+ ? buildClaudeSpawnSpec(session, { attachments: resolvedAttachments })
+ : buildCodexSpawnSpec(session, { attachments: resolvedAttachments });
+ if (spawnSpec?.error) {
+ return wsSend(ws, { type: 'error', message: spawnSpec.error });
}
// === Detached process with file-based I/O ===
@@ -1438,7 +2172,30 @@ function handleMessage(ws, msg, options = {}) {
const outputPath = path.join(dir, 'output.jsonl');
const errorPath = path.join(dir, 'error.log');
- fs.writeFileSync(inputPath, text);
+ if (isClaudeSession(session) && resolvedAttachments.length > 0) {
+ const content = [];
+ if (textValue) content.push({ type: 'text', text: textValue });
+ for (const attachment of resolvedAttachments) {
+ const data = fs.readFileSync(attachment.path).toString('base64');
+ content.push({
+ type: 'image',
+ source: {
+ type: 'base64',
+ media_type: attachment.mime,
+ data,
+ },
+ });
+ }
+ fs.writeFileSync(inputPath, `${JSON.stringify({
+ type: 'user',
+ message: {
+ role: 'user',
+ content,
+ },
+ })}\n`);
+ } else {
+ fs.writeFileSync(inputPath, textValue);
+ }
const inputFd = fs.openSync(inputPath, 'r');
const outputFd = fs.openSync(outputPath, 'w');
@@ -1446,9 +2203,9 @@ function handleMessage(ws, msg, options = {}) {
let proc;
try {
- proc = spawn(CLAUDE_PATH, args, {
- env,
- cwd: session.cwd || process.env.HOME || process.env.USERPROFILE || process.cwd(),
+ proc = spawn(spawnSpec.command, spawnSpec.args, {
+ env: spawnSpec.env,
+ cwd: spawnSpec.cwd,
stdio: [inputFd, outputFd, errorFd],
detached: !IS_WIN,
windowsHide: true,
@@ -1459,7 +2216,8 @@ function handleMessage(ws, msg, options = {}) {
fs.closeSync(errorFd);
cleanRunDir(currentSessionId);
plog('ERROR', 'process_spawn_fail', { sessionId: currentSessionId.slice(0, 8), error: err.message });
- return wsSend(ws, { type: 'error', message: `启动 Claude 失败: ${err.message}` });
+ const agent = getSessionAgent(session);
+ return wsSend(ws, { type: 'error', message: formatRuntimeError(agent, err.message, { exitCode: null, signal: null }) });
}
fs.closeSync(inputFd);
@@ -1472,10 +2230,11 @@ function handleMessage(ws, msg, options = {}) {
plog('INFO', 'process_spawn', {
sessionId: currentSessionId.slice(0, 8),
pid: proc.pid,
- mode: permMode,
+ agent: getSessionAgent(session),
+ mode: spawnSpec.mode,
model: session.model || 'default',
- resume: !!session.claudeSessionId,
- args: args.join(' '),
+ resume: spawnSpec.resume,
+ args: spawnSpec.args.join(' '),
});
// Fast exit detection (while Node.js is running)
@@ -1490,85 +2249,33 @@ function handleMessage(ws, msg, options = {}) {
setTimeout(() => handleProcessComplete(currentSessionId, code, signal), 300);
});
- const entry = { pid: proc.pid, ws, fullText: '', toolCalls: [], lastCost: null, tailer: null };
+ const entry = {
+ pid: proc.pid,
+ ws,
+ agent: getSessionAgent(session),
+ cwd: spawnSpec.cwd,
+ fullText: '',
+ attachments: resolvedAttachments,
+ toolCalls: [],
+ lastCost: null,
+ lastUsage: null,
+ lastError: null,
+ errorSent: false,
+ tailer: null,
+ };
activeProcesses.set(currentSessionId, entry);
+ sendSessionList(ws);
// Tail the output file for real-time streaming
entry.tailer = new FileTailer(outputPath, (line) => {
try {
const event = JSON.parse(line);
- processClaudeEvent(entry, event, currentSessionId);
+ processRuntimeEvent(entry, event, currentSessionId);
} catch {}
});
entry.tailer.start();
}
-// === Claude Event Processing ===
-function processClaudeEvent(entry, event, sessionId) {
- if (!event || !event.type) return;
-
- switch (event.type) {
- case 'system':
- if (event.session_id) {
- const session = loadSession(sessionId);
- if (session) {
- session.claudeSessionId = event.session_id;
- saveSession(session);
- }
- }
- break;
-
- case 'assistant': {
- const content = event.message?.content;
- if (!Array.isArray(content)) break;
-
- for (const block of content) {
- if (block.type === 'text' && block.text) {
- entry.fullText += block.text;
- wsSend(entry.ws, { type: 'text_delta', text: block.text });
- } else if (block.type === 'tool_use') {
- const toolInput = sanitizeToolInput(block.name, block.input);
- const tc = { name: block.name, id: block.id, input: toolInput, done: false };
- entry.toolCalls.push(tc);
- wsSend(entry.ws, { type: 'tool_start', name: block.name, toolUseId: block.id, input: tc.input });
- } else if (block.type === 'tool_result') {
- const resultText = typeof block.content === 'string'
- ? block.content
- : Array.isArray(block.content)
- ? block.content.map(c => c.text || '').join('\n')
- : JSON.stringify(block.content);
- const tc = entry.toolCalls.find(t => t.id === block.tool_use_id);
- if (tc) { tc.done = true; tc.result = resultText.slice(0, 2000); }
- wsSend(entry.ws, { type: 'tool_end', toolUseId: block.tool_use_id, result: resultText.slice(0, 2000) });
- }
- }
-
- if (event.session_id) {
- const session = loadSession(sessionId);
- if (session && !session.claudeSessionId) {
- session.claudeSessionId = event.session_id;
- saveSession(session);
- }
- }
- break;
- }
-
- case 'result': {
- const session = loadSession(sessionId);
- if (session) {
- if (event.session_id) session.claudeSessionId = event.session_id;
- if (event.total_cost_usd) session.totalCost = (session.totalCost || 0) + event.total_cost_usd;
- saveSession(session);
- }
- entry.lastCost = event.total_cost_usd || null;
- if (entry.ws && event.total_cost_usd !== undefined) {
- wsSend(entry.ws, { type: 'cost', costUsd: session?.totalCost || 0 });
- }
- break;
- }
- }
-}
-
function truncateObj(obj, maxLen) {
const s = JSON.stringify(obj);
if (s.length <= maxLen) return obj;
@@ -1598,6 +2305,30 @@ function sanitizeToolInput(toolName, input) {
return truncateObj(parsed, 500);
}
+const {
+ buildClaudeSpawnSpec,
+ buildCodexSpawnSpec,
+ processClaudeEvent,
+ processCodexEvent,
+ processRuntimeEvent,
+} = createAgentRuntime({
+ processEnv: process.env,
+ CLAUDE_PATH,
+ CODEX_PATH,
+ MODEL_MAP,
+ loadModelConfig,
+ applyCustomTemplateToSettings,
+ loadCodexConfig,
+ prepareCodexCustomRuntime,
+ wsSend,
+ truncateObj,
+ sanitizeToolInput,
+ loadSession,
+ saveSession,
+ setRuntimeSessionId,
+ getRuntimeSessionId,
+});
+
// === Check Update ===
function handleCheckUpdate(ws) {
const localVersion = (() => {
@@ -1653,6 +2384,40 @@ function handleCheckUpdate(ws) {
// === Native Session Import ===
const CLAUDE_PROJECTS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'projects');
+const CODEX_SESSIONS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.codex', 'sessions');
+const CODEX_STATE_DB_PATH = path.join(process.env.HOME || process.env.USERPROFILE || '', '.codex', 'state_5.sqlite');
+const CODEX_LOG_DB_PATH = path.join(process.env.HOME || process.env.USERPROFILE || '', '.codex', 'logs_1.sqlite');
+
+function resolveClaudeSessionLocalMeta(claudeSessionId) {
+ if (!claudeSessionId) return null;
+ try {
+ const dirs = fs.readdirSync(CLAUDE_PROJECTS_DIR).filter((dir) => {
+ try { return fs.statSync(path.join(CLAUDE_PROJECTS_DIR, dir)).isDirectory(); } catch { return false; }
+ });
+ for (const dir of dirs) {
+ const filePath = path.join(CLAUDE_PROJECTS_DIR, dir, `${sanitizeId(claudeSessionId)}.jsonl`);
+ if (!fs.existsSync(filePath)) continue;
+ try {
+ const content = fs.readFileSync(filePath, 'utf8');
+ const lines = content.split('\n');
+ let cwd = null;
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+ try {
+ const entry = JSON.parse(trimmed);
+ if (entry.type === 'user' && entry.cwd) {
+ cwd = entry.cwd;
+ break;
+ }
+ } catch {}
+ }
+ return { cwd, projectDir: dir, filePath };
+ } catch {}
+ }
+ } catch {}
+ return null;
+}
function parseJsonlToMessages(lines) {
const messages = [];
@@ -1698,6 +2463,18 @@ function parseJsonlToMessages(lines) {
return messages;
}
+const {
+ parseCodexRolloutLines,
+ getCodexRolloutFiles,
+ getImportedCodexThreadIds,
+ parseCodexRolloutFile,
+} = createCodexRolloutStore({
+ codexSessionsDir: CODEX_SESSIONS_DIR,
+ sessionsDir: SESSIONS_DIR,
+ normalizeSession,
+ sanitizeToolInput,
+});
+
function getImportedSessionIds() {
const imported = new Set();
try {
@@ -1820,17 +2597,134 @@ function handleImportNativeSession(ws, msg) {
title,
created: existingSession?.created || new Date().toISOString(),
updated: new Date().toISOString(),
+ agent: 'claude',
claudeSessionId: sessionId,
+ codexThreadId: null,
importedFrom: projectDir,
model: existingSession?.model || null,
permissionMode: existingSession?.permissionMode || 'yolo',
totalCost: existingSession?.totalCost || 0,
+ totalUsage: existingSession?.totalUsage || { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
messages,
cwd: cwd || existingSession?.cwd || null,
};
saveSession(session);
wsSessionMap.set(ws, id);
- wsSend(ws, { type: 'session_info', sessionId: id, messages: session.messages, title: session.title, mode: session.permissionMode, model: modelShortName(session.model), cwd: session.cwd });
+ wsSend(ws, {
+ type: 'session_info',
+ sessionId: id,
+ messages: session.messages,
+ title: session.title,
+ mode: session.permissionMode,
+ model: sessionModelLabel(session),
+ agent: getSessionAgent(session),
+ cwd: session.cwd,
+ totalCost: session.totalCost || 0,
+ totalUsage: session.totalUsage || null,
+ updated: session.updated,
+ hasUnread: false,
+ historyPending: false,
+ isRunning: false,
+ });
+ sendSessionList(ws);
+}
+
+function handleListCodexSessions(ws) {
+ const imported = getImportedCodexThreadIds();
+ const items = [];
+ const seen = new Set();
+ for (const filePath of getCodexRolloutFiles()) {
+ const parsed = parseCodexRolloutFile(filePath);
+ if (!parsed?.meta?.threadId) continue;
+ if (seen.has(parsed.meta.threadId)) continue;
+ seen.add(parsed.meta.threadId);
+ const title = parsed.meta.title || parsed.meta.threadId.slice(0, 20);
+ items.push({
+ threadId: parsed.meta.threadId,
+ title,
+ cwd: parsed.meta.cwd || null,
+ updatedAt: parsed.meta.updatedAt || null,
+ cliVersion: parsed.meta.cliVersion || '',
+ source: parsed.meta.source || '',
+ rolloutPath: filePath,
+ alreadyImported: imported.has(parsed.meta.threadId),
+ });
+ }
+ wsSend(ws, { type: 'codex_sessions', sessions: items });
+}
+
+function handleImportCodexSession(ws, msg) {
+ const threadId = String(msg?.threadId || '').trim();
+ if (!threadId) {
+ return wsSend(ws, { type: 'error', message: '缺少 threadId' });
+ }
+
+ let parsed = null;
+ const requestedPath = msg?.rolloutPath ? path.resolve(String(msg.rolloutPath)) : '';
+ if (requestedPath && requestedPath.startsWith(CODEX_SESSIONS_DIR) && fs.existsSync(requestedPath)) {
+ parsed = parseCodexRolloutFile(requestedPath);
+ }
+ if (!parsed) {
+ for (const filePath of getCodexRolloutFiles()) {
+ const candidate = parseCodexRolloutFile(filePath);
+ if (candidate?.meta?.threadId === threadId) {
+ parsed = candidate;
+ break;
+ }
+ }
+ }
+
+ if (!parsed || parsed.meta.threadId !== threadId) {
+ return wsSend(ws, { type: 'error', message: '未找到对应的 Codex 会话文件' });
+ }
+
+ let existingSession = null;
+ try {
+ for (const f of fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'))) {
+ try {
+ const s = normalizeSession(JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8')));
+ if (s.codexThreadId === threadId) { existingSession = s; break; }
+ } catch {}
+ }
+ } catch {}
+
+ const id = existingSession ? existingSession.id : crypto.randomUUID();
+ const session = {
+ id,
+ title: parsed.meta.title || existingSession?.title || threadId.slice(0, 20),
+ created: existingSession?.created || new Date().toISOString(),
+ updated: new Date().toISOString(),
+ agent: 'codex',
+ claudeSessionId: null,
+ codexThreadId: threadId,
+ importedFrom: 'codex',
+ importedRolloutPath: parsed.filePath,
+ model: existingSession?.model || null,
+ permissionMode: existingSession?.permissionMode || 'yolo',
+ totalCost: existingSession?.totalCost || 0,
+ totalUsage: parsed.totalUsage || existingSession?.totalUsage || { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
+ messages: parsed.messages,
+ cwd: parsed.meta.cwd || existingSession?.cwd || null,
+ };
+
+ saveSession(session);
+ wsSessionMap.set(ws, id);
+ wsSend(ws, {
+ type: 'session_info',
+ sessionId: id,
+ messages: session.messages,
+ title: session.title,
+ mode: session.permissionMode,
+ model: sessionModelLabel(session),
+ agent: getSessionAgent(session),
+ cwd: session.cwd,
+ totalCost: session.totalCost || 0,
+ totalUsage: session.totalUsage || null,
+ updated: session.updated,
+ hasUnread: false,
+ historyPending: false,
+ isRunning: false,
+ });
sendSessionList(ws);
}