Merge remote-tracking branch 'origin/master' into pr/cod-455-xlsx-preview

# Conflicts:
#	CLAUDE.md
#	config/test-suites.ts
This commit is contained in:
Aamer Akhter committed 2026-10-04 20:08:01 -04:00
commit ab96e74e69
95 files changed
+11228 -274

No files matched your search

+499 -11
View File
@@ -319,6 +319,8 @@ const _SSE_HANDLER_MAP = [
// Session order (global tab order sync, COD-131)
[SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'],
// Owner tab layout (grouped vertical rail)
[SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged'],
// Web tabs (dashboard URLs)
[SSE_EVENTS.WEBVIEW_CHANGED, '_onWebviewChanged'],
@@ -581,6 +583,12 @@ class CodemanApp {
this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results
this.sessionOrder = []; // Track tab order for drag-and-drop reordering
this.draggedTabId = null; // Currently dragged tab session ID
// Owner tab layout (GET /api/tab-layout), read-only here: it only changes how
// the vertical rail GROUPS rows. sessionOrder above stays the tab order.
this.tabLayout = null;
this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed
this._hiddenTabGroupByRef = new Map(); // 'session:<id>' -> collapsed group id
this._lastTabGroupStructureKey = null;
this.cases = [];
this.currentRun = null;
this.totalTokens = 0;
@@ -1242,6 +1250,12 @@ class CodemanApp {
// Use capture to handle before terminal
document.addEventListener('keydown', (e) => {
// A field that exists to show what a key does (Settings → Key tester, `data-raw-keys`) must
// receive every chord untouched. Without this, probing Ctrl+W killed the active session,
// Ctrl+L cleared the terminal and Escape closed Settings: this listener runs in the capture
// phase, before the field's own handler. Must stay the first statement.
if (e.target?.closest?.('[data-raw-keys]')) return;
// Don't intercept keys during CJK IME composition
if (e.isComposing || e.keyCode === 229) return;
@@ -4361,6 +4375,10 @@ class CodemanApp {
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
this.syncSessionOrder();
// (Re)read the owner tab layout on every init, including SSE reconnects: a
// tab:layoutChanged sent while this client was disconnected is never replayed.
this._loadTabLayout();
if (data.respawnStatus) {
this.respawnStatus = data.respawnStatus;
} else {
@@ -5183,6 +5201,12 @@ class CodemanApp {
_updateActiveTabImmediate(sessionId) {
const container = this.$('sessionTabs');
if (!container) return;
// Grouped rail: selecting a session hidden in a collapsed group must show it
// (and re-hide the previous exception), which a class toggle cannot do.
if (this._isTabGroupStructureStale()) {
this._fullRenderSessionTabs();
return;
}
const tabs = container.querySelectorAll('.session-tab[data-id]');
for (const tab of tabs) {
if (tab.dataset.id === sessionId) {
@@ -5191,6 +5215,7 @@ class CodemanApp {
tab.classList.remove('active');
}
}
this._syncTabTreeSelection(container);
// #257: selection used to stop at the class toggle. On phones/tablets the
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
// Alt+N or a push notification could stay parked off-screen.
@@ -5342,7 +5367,12 @@ class CodemanApp {
const container = this.$('sessionTabs');
const existingTabs = container.querySelectorAll('.session-tab[data-id]');
const existingIds = new Set([...existingTabs].map(t => t.dataset.id));
const currentIds = new Set(this.sessions.keys());
// Grouped rail: a collapsed group keeps its rows out of the DOM, so compare
// against the rows the projection SHOWS, not every live session.
const groupProjection = this._projectTabGroups();
const currentIds = groupProjection
? new Set(groupProjection.visibleRefs.filter((ref) => ref.kind === 'session').map((ref) => ref.id))
: new Set(this.sessions.keys());
// Web tabs live in the same strip but are not in this.sessions, so they need
// their own change check. Without it, the session-only comparison below is
@@ -5351,14 +5381,20 @@ class CodemanApp {
const existingWebIds = [...container.querySelectorAll('.session-tab[data-webview-id]')].map(
t => t.dataset.webviewId
);
const wantedWebIds = (this.webviewOrder || []).filter(id => this.webviews?.has(id));
const wantedWebIds = groupProjection
? groupProjection.visibleRefs.filter((ref) => ref.kind === 'webview').map((ref) => ref.id)
: (this.webviewOrder || []).filter(id => this.webviews?.has(id));
const webTabsUnchanged =
existingWebIds.length === wantedWebIds.length && existingWebIds.every((id, i) => id === wantedWebIds[i]);
// Check if we can do incremental update (same session IDs and same web tabs)
// The grouped rail's structure (sections, collapse, the shown exception) can
// change while the id sets stay equal; the in-place patch below cannot move
// or hide a row, so any structural change takes the full rebuild.
const canIncremental = existingIds.size === currentIds.size &&
[...existingIds].every(id => currentIds.has(id)) &&
webTabsUnchanged;
webTabsUnchanged &&
!this._isTabGroupStructureStale(groupProjection);
if (canIncremental) {
// Read once for the whole pass, like the full-rebuild path: this touches
@@ -5545,6 +5581,15 @@ class CodemanApp {
subagentBadgeEl.remove();
}
}
// Grouped tree: the loop above can re-sort rows (`style.order`), move the
// highlight, and change the alerts a collapsed header stands in for, none
// of which rebuilds the rail. Keep what assistive tech and the headers
// report in step. The flat list (role=tablist) never takes this branch.
if (groupProjection && container.getAttribute('role') === 'tree') {
this._syncTabTreeSelection(container);
this._applyTabTreePositions(container);
this._syncTabGroupHeaderAlerts(container, groupProjection);
}
} else {
// Full rebuild needed (sessions added/removed)
this._fullRenderSessionTabs();
@@ -5676,6 +5721,12 @@ class CodemanApp {
const prevScrollTop = container.scrollTop;
const prevActiveTabId = this._lastRenderedActiveTabId;
const isFirstRender = !container.querySelector('.session-tab');
// The rebuild below destroys the focused row. In the grouped tree, put focus
// back on the same item (by identity) so a background render or a keyboard
// collapse does not drop a keyboard user to <body>.
const focusWasInside = container.contains(document.activeElement);
const focusIdentity = this._tabFocusIdentity || (focusWasInside ? this._tabTreeIdentity(document.activeElement) : null);
this._tabFocusIdentity = null;
// Build tabs HTML using array for better string concatenation performance.
// Iterate in sessionOrder to respect the user's custom tab arrangement, on
@@ -5696,6 +5747,10 @@ class CodemanApp {
// layout, and the tabs then carry no inline order at all — the header
// strip's markup is byte-identical to before.
const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));
// One row per session, in tab order. The flat strip emits them as-is; the
// grouped rail places the SAME markup into its sections, so a row never
// differs between the two (badge = Alt+N slot in sessionOrder either way).
const rowHtml = new Map();
let _tabIdx = 0;
for (const id of tabOrder) {
const session = this.sessions.get(id);
@@ -5759,7 +5814,7 @@ class CodemanApp {
const inlineSessionActions = this.shouldInlineSessionActions();
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</button></span>`;
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
<span class="tab-status ${status}" aria-hidden="true"></span>
@@ -5782,12 +5837,41 @@ class CodemanApp {
_tabIdx++;
}
// Web tabs (dashboard URLs) render after the session tabs, continuing the
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
const groupProjection = this._projectTabGroups();
if (groupProjection) {
// Grouped vertical rail. Web tabs keep their flat-strip Alt+N slot (after
// every session), wherever their group puts them.
const webviewSlots = new Map(
(this.webviewOrder || []).filter((wid) => this.webviews?.has(wid)).map((wid, i) => [wid, _tabIdx + i])
);
parts.push(
window.CodemanTabLayout.renderProjection(
groupProjection,
(ref) =>
ref.kind === 'session'
? rowHtml.get(ref.id) || ''
: this.renderWebviewTab?.(ref.id, webviewSlots.get(ref.id) ?? Infinity) || '',
escapeHtml
)
);
this._hiddenTabGroupByRef = new Map(Object.entries(groupProjection.hiddenTabGroupByRef));
} else {
parts.push(...rowHtml.values());
// Web tabs (dashboard URLs) render after the session tabs, continuing the
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
this._hiddenTabGroupByRef = new Map();
}
this._lastTabGroupStructureKey = this._tabGroupStructureKey(groupProjection);
container.innerHTML = parts.join('');
container.classList.toggle('session-tabs--grouped', !!groupProjection);
this._applyTabListRole(container, !!groupProjection);
if (groupProjection) {
this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside });
this._syncTabGroupHeaderAlerts(container, groupProjection);
}
// Put the strip back where the user left it, then reveal the active tab
// only when it CHANGED (or on the first paint). Restoring unconditionally
@@ -5838,6 +5922,12 @@ class CodemanApp {
}
this._tabKeydownHandler = (e) => {
// The grouped rail is a tree with its own key model; everything else
// (header strip, sidebar, flat rail) keeps the tab-strip walk below.
if (container.getAttribute('role') === 'tree') {
this._handleTabTreeKeydown(e, container);
return;
}
// Up/Down are aliases of Left/Right, not replacements: the strip stays
// arrow-key navigable exactly as before, the vertical sidebar just gains
// the axis a user reaches for there.
@@ -5862,8 +5952,7 @@ class CodemanApp {
// Enter or Space activates the tab
if ((e.key === 'Enter' || e.key === ' ') && currentIndex >= 0) {
e.preventDefault();
const sessionId = tabs[currentIndex].dataset.id;
this.selectSession(sessionId, { forceReload: true });
this._activateTabRow(tabs[currentIndex]);
return;
}
@@ -5894,6 +5983,250 @@ class CodemanApp {
};
container.addEventListener('keydown', this._tabKeydownHandler);
// Grouped tree: a row or header focused by pointer becomes the tab stop, so
// Tab-ing away and back returns to it and there is still exactly one stop.
if (!this._tabTreeFocusinHandler) {
this._tabTreeFocusinHandler = (e) => {
if (container.getAttribute('role') !== 'tree') return;
const item = e.target?.closest?.('[role="treeitem"]');
if (item && container.contains(item)) this._setTabTreeStop(container, item);
};
container.addEventListener('focusin', this._tabTreeFocusinHandler);
}
}
/** Select a session row or open a web-tab row (Enter/Space, either layout). */
_activateTabRow(row) {
if (row?.dataset.webviewId) return this.openWebview(row.dataset.webviewId);
if (row?.dataset.id) return this.selectSession(row.dataset.id, { forceReload: true });
return undefined;
}
// ═══════════════════════════════════════════════════════════════
// Grouped rail: tree semantics and keyboard model
// ═══════════════════════════════════════════════════════════════
//
// Only the GROUPED vertical rail is a tree. #sessionTabs becomes role=tree;
// named-group headers are level-1 treeitems that own their rows (level 2);
// ungrouped rows and a collapsed group's kept selection are level 1. One item
// carries tabindex=0 (roving), every control inside a row is removed from the
// tab order, and focus is restored by identity across full re-renders. The
// flat rail and the header strip keep role=tablist / role=tab untouched.
/** #sessionTabs is a tablist (index.html) except while it holds the grouped tree. */
_applyTabListRole(container, grouped) {
if (grouped) {
container.setAttribute('role', 'tree');
container.setAttribute('aria-label', 'Sessions');
} else if (container.getAttribute('role') === 'tree') {
container.setAttribute('role', 'tablist');
container.setAttribute('aria-label', 'Session tabs');
}
}
/** Stable identity of a tree item (or anything inside one) across re-renders. */
_tabTreeIdentity(element) {
const item = element?.closest?.('[data-tab-group-header], .session-tab');
if (!item) return null;
if (item.dataset.tabGroupHeader) return `group:${item.dataset.tabGroupHeader}`;
if (item.dataset.webviewId) return `webview:${item.dataset.webviewId}`;
if (item.dataset.id) return `session:${item.dataset.id}`;
return null;
}
/**
* Visible tree items in the order the eye reads them: each section's header,
* then its rows. A sorted rail paints rows with the flex `order` property
* inside their own group column, so rows are ordered by COMPUTED order within
* a section (stable, so the unsorted rail keeps DOM order).
*/
_tabTreeItems(container) {
const sorted = this.isTabRailSorted();
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
const items = [];
for (const section of container.querySelectorAll('.tab-layout-group')) {
const header = section.querySelector(':scope > [role="treeitem"]');
if (header) items.push(header);
const rows = [...section.querySelectorAll('.session-tab[role="treeitem"]:not(.tab-filtered-out)')];
if (sorted) rows.sort((a, b) => orderOf(a) - orderOf(b));
items.push(...rows);
}
return items;
}
/** Move the single tab stop to `item` (every other tree item gets -1). */
_setTabTreeStop(container, item) {
for (const el of container.querySelectorAll('[role="treeitem"]')) el.tabIndex = el === item ? 0 : -1;
}
/**
* Turn freshly rendered rows into tree items and place the roving tab stop.
* Rows arrive as the flat strip's markup (role=tab, tabindex=0 on the row and
* its controls); only their semantics change here, never their content.
*/
_applyTabTreeSemantics(container, { identity = null, refocus = false } = {}) {
for (const row of container.querySelectorAll('.session-tab')) {
row.setAttribute('role', 'treeitem');
row.setAttribute('aria-selected', row.classList.contains('active') ? 'true' : 'false');
row.setAttribute('aria-level', row.closest('[role="group"]') ? '2' : '1');
// Controls stay clickable, but leave the tab order: the tree has ONE stop,
// and a row's actions are reachable from it with Shift+F10 / ContextMenu.
for (const control of row.querySelectorAll('[tabindex], button, a[href], input, select, textarea')) {
control.tabIndex = -1;
}
}
for (const header of container.querySelectorAll('[data-tab-group-header]')) header.setAttribute('aria-level', '1');
const items = this._applyTabTreePositions(container);
const byIdentity = (id) => (id ? items.find((item) => this._tabTreeIdentity(item) === id) : null);
// A focused row that a collapse just hid hands focus to its group header.
const hiddenIn = identity ? this._hiddenTabGroupByRef?.get(identity) : null;
const target =
byIdentity(identity) ||
(hiddenIn ? byIdentity(`group:${hiddenIn}`) : null) ||
items.find((item) => item.getAttribute('aria-selected') === 'true') ||
items[0];
if (!target) return;
this._setTabTreeStop(container, target);
if (refocus && document.activeElement !== target) target.focus();
}
/**
* aria-posinset / aria-setsize within each level, in PAINTED order: the
* level-1 run (headers, ungrouped rows, a collapsed group's kept row) and each
* group's own rows. Runs after every full render AND after an incremental pass,
* because the activity-sorted rail re-sorts rows in place. Returns the items.
*/
_applyTabTreePositions(container) {
const items = this._tabTreeItems(container);
const sets = new Map();
for (const item of items) {
const owner = item.getAttribute('aria-level') === '2' ? item.closest('[role="group"]') : container;
if (!sets.has(owner)) sets.set(owner, []);
sets.get(owner).push(item);
}
for (const members of sets.values()) {
members.forEach((item, index) => {
const setsize = String(members.length);
const posinset = String(index + 1);
if (item.getAttribute('aria-setsize') !== setsize) item.setAttribute('aria-setsize', setsize);
if (item.getAttribute('aria-posinset') !== posinset) item.setAttribute('aria-posinset', posinset);
});
}
return items;
}
/**
* A collapsed group hides its rows, including ones that need the user. Its
* header takes the most urgent hidden alert in the tab alert language
* (`tab-alert-action` red, `tab-alert-idle` yellow), so a permission prompt
* behind a collapse is never invisible. Patched in place on both render paths:
* alerts change without a rebuild.
*/
_syncTabGroupHeaderAlerts(container, projection) {
const alerts = window.CodemanTabLayout?.hiddenGroupAlerts(projection, (id) => this.tabAlerts?.get(id)) || {};
for (const header of container.querySelectorAll('[data-tab-group-header]')) {
const alert = alerts[header.dataset.tabGroupHeader];
header.classList.toggle('tab-alert-action', alert === 'action');
header.classList.toggle('tab-alert-idle', alert === 'idle');
}
}
/** Keep aria-selected on the grouped tree in step with the .active class. */
_syncTabTreeSelection(container) {
if (container?.getAttribute('role') !== 'tree') return;
for (const row of container.querySelectorAll('.session-tab')) {
row.setAttribute('aria-selected', row.classList.contains('active') ? 'true' : 'false');
}
}
/**
* Keyboard model of the grouped tree (WAI-ARIA tree view): Up/Down walk the
* visible items, Home/End jump, Right expands a header or enters it, Left
* collapses a header or climbs from a row to its header, Enter/Space select a
* row or toggle a header, Shift+F10 / ContextMenu open a row's actions.
*/
_handleTabTreeKeydown(e, container) {
if (e.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
const items = this._tabTreeItems(container);
const current = e.target?.closest?.('[role="treeitem"]');
// A key pressed on a control INSIDE a row (its close or overflow button,
// focused by a click or handed focus back by the action menu) belongs to
// that control: Enter there must reopen the menu, not re-select the row.
// Same contract as the flat list, which acts only on a focused row.
if (!current || current !== e.target) return;
const index = items.indexOf(current);
if (index < 0) return;
const groupId = current.dataset.tabGroupHeader || null;
const expanded = current.getAttribute('aria-expanded') === 'true';
// A group with no open rows is a leaf (no aria-expanded): nothing to open.
const expandable = current.hasAttribute('aria-expanded');
const focusAt = (next) => {
if (!next) return;
this._setTabTreeStop(container, next);
next.focus();
};
const toggle = (collapse) => {
// The toggle re-renders the rail; keep focus on this header through it.
this._tabFocusIdentity = `group:${groupId}`;
this.toggleTabGroupCollapsed(groupId, collapse);
};
switch (e.key) {
case 'ArrowDown':
case 'ArrowUp': {
const step = e.key === 'ArrowDown' ? 1 : -1;
focusAt(items[(index + step + items.length) % items.length]);
break;
}
case 'Home':
focusAt(items[0]);
break;
case 'End':
focusAt(items[items.length - 1]);
break;
case 'ArrowRight':
if (!groupId || !expandable) return;
if (!expanded) toggle(false);
else {
const child = items.find((item) => item.closest('[role="group"]')?.id === current.getAttribute('aria-owns'));
if (!child) return;
focusAt(child);
}
break;
case 'ArrowLeft':
if (groupId) {
if (!expandable || !expanded) return;
toggle(true);
} else {
const group = current.closest('[role="group"]');
const header = group ? container.querySelector(`[aria-owns="${CSS.escape(group.id)}"]`) : null;
if (!header) return;
focusAt(header);
}
break;
case 'Enter':
case ' ':
if (groupId) toggle();
else this._activateTabRow(current);
break;
case 'F10':
case 'ContextMenu':
if (e.key === 'F10' && !e.shiftKey) return;
if (current.dataset.id) {
this.openTabRailActionMenu?.(
{ preventDefault() {}, stopPropagation() {}, currentTarget: current },
current.dataset.id
);
} else if (current.dataset.webviewId) {
this.showWebviewModal?.(current.dataset.webviewId);
} else return;
break;
default:
return;
}
e.preventDefault();
}
handleSessionTabClick(event, sessionId) {
@@ -5978,6 +6311,141 @@ class CodemanApp {
}
}
// ═══════════════════════════════════════════════════════════════
// Owner tab layout: grouped vertical rail (read-only)
// ═══════════════════════════════════════════════════════════════
//
// The server owns named tab groups (GET /api/tab-layout, tab-layout*.ts) and
// already projects them onto the global session order, so sessionOrder, Alt+N,
// Ctrl+Tab and every other order consumer are untouched here. This layer only
// decides how the VERTICAL rail draws rows: in sections, with per-device
// collapse. With no groups (or any read failure) the rail is the flat list it
// has always been.
_ensureTabLayoutCoordinator() {
if (this._tabLayoutCoordinator) return this._tabLayoutCoordinator;
if (!window.CodemanTabLayout) return null;
this._tabLayoutCoordinator = window.CodemanTabLayout.createLoadCoordinator({
fetchLayout: async () => {
const data = await this._apiJson('/api/tab-layout');
if (!data?.layout) throw new Error('Tab layout unavailable');
return data.layout;
},
applyLayout: (layout) => this._applyTabLayout(layout),
applyFallback: () => this._applyTabLayout(null),
// 5 s, 10 s, 20 s, 40 s, then stop until the next SSE init or
// tab:layoutChanged asks again: an unreachable server must not cost a
// fetch every 5 s for as long as the page stays open.
retryDelayMs: 5000,
maxRetryDelayMs: 60000,
maxRetries: 4,
scheduleRetry: (retry, delayMs) => setTimeout(retry, delayMs),
cancelRetry: (timer) => clearTimeout(timer),
});
return this._tabLayoutCoordinator;
}
_loadTabLayout() {
return this._ensureTabLayoutCoordinator()?.load() ?? Promise.resolve(false);
}
/** SSE tab:layoutChanged carries `{ owner, version }`; the layout itself is re-read. */
_onTabLayoutChanged(data) {
const me = window.__codemanUser;
// Another user's layout changed: nothing of ours moved. (The GET is
// owner-scoped server-side, so this is a saved request, not a guard.)
if (me?.multiUser && typeof data?.owner === 'string' && data.owner !== me.username) return;
if (Number.isSafeInteger(data?.version) && this.tabLayout && data.version <= this.tabLayout.version) return;
this._loadTabLayout();
}
/** Adopt a layout read (or null after a failed read, which renders flat). */
_applyTabLayout(layout) {
let next = null;
if (layout) {
try {
next = window.CodemanTabLayout.normalizeLayout(layout);
} catch {
next = null;
}
}
// An overtaken response is already dropped by the coordinator; this guards a
// reordering between the coordinator and an SSE-triggered reload.
if (next && this.tabLayout && next.version < this.tabLayout.version) return;
this.tabLayout = next;
const storage = this._getTabCollapseStorage();
const collapsed = storage && next
? window.CodemanTabLayout.loadCollapsedGroupIds(storage, next.groups.map((group) => group.id))
: { ids: [], ok: !next };
if (!collapsed.ok) this._tabCollapseStorageFailed = true;
this.collapsedTabGroupIds = new Set(collapsed.ids);
// The server announces a layout change on every session create/close, web
// tab create/delete and order PUT, and most of those move nothing on this
// rail (always so on the flat rail, which is every owner without groups).
// Rebuild only when what the rail would draw actually changed.
if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs();
}
/** localStorage, or null once it has failed (collapse then stays all-expanded). */
_getTabCollapseStorage() {
if (this._tabCollapseStorageFailed) return null;
try {
return window.localStorage;
} catch {
this._tabCollapseStorageFailed = true;
return null;
}
}
/**
* The grouped projection for the CURRENT render, or null when the rail should
* render flat: horizontal strip (incl. phones and the sidebar, which force it),
* no layout yet, a failed read, or a layout without groups.
*/
_projectTabGroups() {
if (!this.tabLayout || this._tabOrientation() !== 'vertical' || !window.CodemanTabLayout) return null;
return window.CodemanTabLayout.project(this.tabLayout, {
// sessionOrder, not the sessions Map: rows are built in that order, so a
// session the layout has not placed yet lands where the flat strip has it.
liveSessionIds: this.sessionOrder.filter((id) => this.sessions.has(id)),
openWebviewIds: (this.webviewOrder || []).filter((id) => this.webviews?.has(id)),
collapsedGroupIds: [...this.collapsedTabGroupIds],
activeSessionId: this.activeSessionId,
activeWebviewId: this.activeWebviewId,
});
}
_tabGroupStructureKey(projection) {
return window.CodemanTabLayout?.structureKey(this.tabLayout, projection, [...this.collapsedTabGroupIds]) ?? null;
}
/** True when the DOM's grouping no longer matches what a render would draw. */
_isTabGroupStructureStale(projection = this._projectTabGroups()) {
return this._tabGroupStructureKey(projection) !== this._lastTabGroupStructureKey;
}
/**
* Collapse/expand one group (header click). Per-device: stored in localStorage,
* never sent to the server, so collapsing on a laptop leaves the desktop alone.
* A storage failure leaves every group expanded rather than half-remembered.
*/
toggleTabGroupCollapsed(groupId, forceCollapsed) {
if (!this.tabLayout?.groups?.some((group) => group.id === groupId)) return false;
const next = new Set(this.collapsedTabGroupIds);
const shouldCollapse = forceCollapsed === undefined ? !next.has(groupId) : forceCollapsed === true;
if (shouldCollapse) next.add(groupId);
else next.delete(groupId);
const storage = this._getTabCollapseStorage();
const saved = storage
? window.CodemanTabLayout.saveCollapsedGroupIds(storage, [...next])
: { ids: [], ok: false };
if (!saved.ok) this._tabCollapseStorageFailed = true;
this.collapsedTabGroupIds = new Set(saved.ids);
// The full render also redraws connectors anchored to rows that just moved.
this._fullRenderSessionTabs();
return this.collapsedTabGroupIds.has(groupId) === shouldCollapse;
}
// Set up drag-and-drop handlers on tab elements
setupTabDragHandlers() {
const container = this.$('sessionTabs');
@@ -5989,7 +6457,10 @@ class CodemanApp {
// affordance instead of lying about it — `tabRailSort: 'manual'` is the way
// back to drag-reordering, and Alt+N / Ctrl+Shift+{ } still walk the strip
// order this list is no longer showing.
if (this.isTabRailSorted()) {
// The grouped rail is read-only for now: a flat-order drag cannot express
// "move into that group", and the server would re-rank it within its old
// group anyway. Grouped editing comes with its own drag model.
if (this.isTabRailSorted() || container.classList.contains('session-tabs--grouped')) {
tabs.forEach((tab) => tab.setAttribute('draggable', 'false'));
return;
}
@@ -6080,10 +6551,26 @@ class CodemanApp {
});
}
/**
* Grouped rail: Ctrl+Shift+{ / } may only swap the active session with a
* neighbour in its OWN section. Across a group boundary the server re-ranks
* each group on its own (`putLegacyOrder`), so nothing moves there, no
* session:orderChanged comes back, and this client would keep a swapped
* sessionOrder (and Alt+N targets) that no other device shares. Same reason
* drag is off in the grouped rail. Any other layout: always allowed.
*/
_canSwapActiveTabWith(neighbourId) {
const projection = this._projectTabGroups();
if (!projection) return true;
const sectionOf = (id) => projection.sectionByRef[`session:${id}`];
return sectionOf(this.activeSessionId) === sectionOf(neighbourId);
}
moveActiveTabLeft() {
if (!this.activeSessionId) return;
const idx = this.sessionOrder.indexOf(this.activeSessionId);
if (idx <= 0) return;
if (!this._canSwapActiveTabWith(this.sessionOrder[idx - 1])) return;
[this.sessionOrder[idx - 1], this.sessionOrder[idx]] = [this.sessionOrder[idx], this.sessionOrder[idx - 1]];
this.saveSessionOrder();
this._fullRenderSessionTabs();
@@ -6093,6 +6580,7 @@ class CodemanApp {
if (!this.activeSessionId) return;
const idx = this.sessionOrder.indexOf(this.activeSessionId);
if (idx === -1 || idx >= this.sessionOrder.length - 1) return;
if (!this._canSwapActiveTabWith(this.sessionOrder[idx + 1])) return;
[this.sessionOrder[idx], this.sessionOrder[idx + 1]] = [this.sessionOrder[idx + 1], this.sessionOrder[idx]];
this.saveSessionOrder();
this._fullRenderSessionTabs();
+1
View File
@@ -67,6 +67,7 @@
'Open away digest': '打开离开期间摘要',
'Session Manager': '会话管理器',
'Session actions': '会话操作',
Ungrouped: '未分组',
'Open session manager': '打开会话管理器',
Attachments: '附件',
'Open attachment history': '打开附件历史',
+108
View File
@@ -1822,6 +1822,22 @@
</div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Key tester</h4><span class="set-scope">device</span></div>
<div class="set-group-body">
<div class="set-row has-field" data-search="key tester keyboard shift enter newline diagnose keydown keypress">
<div class="set-row-text">
<span class="set-row-label">Key tester</span>
<span class="set-row-desc">Click the box and press keys to see what this browser reports (key, code, modifiers) for keydown, keypress and keyup. Useful when a shortcut such as Shift+Enter behaves differently on one device. Nothing is sent to a session.</span>
</div>
<input type="text" id="keyTesterInput" class="set-input" data-raw-keys readonly autocomplete="off" spellcheck="false"
placeholder="Click here, then press keys"
onkeydown="app.keyTesterEvent(event)" onkeypress="app.keyTesterEvent(event)" onkeyup="app.keyTesterEvent(event)">
</div>
<pre id="keyTesterLog" class="set-note mono" style="display:none;white-space:pre-wrap" data-i18n-skip></pre>
</div>
</div>
</section>
<!-- ══ Header &amp; Panels ═══════════════════════════════════════ -->
@@ -2172,6 +2188,19 @@
<option value="ultracode">Ultracode</option>
</select>
</div>
<div class="set-row set-row-block" data-search="advisor fable opus sonnet second opinion review consult">
<div class="set-row-text">
<span class="set-row-label">Advisor</span>
<span class="set-row-desc">A stronger model Claude consults before big decisions, on repeated errors and before calling a task done. Uses extra tokens. Switchable in-session with /advisor.</span>
</div>
<div class="set-segment" id="appSettingsAdvisorSegment" role="radiogroup" aria-label="Advisor"></div>
<select id="appSettingsClaudeAdvisor" class="set-select set-field-hidden" aria-hidden="true" tabindex="-1">
<option value="">Default</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="fable">Fable</option>
</select>
</div>
</div>
</div>
@@ -2474,6 +2503,30 @@
</div>
</div>
</div>
<div class="set-group" id="mcpSyncGroup">
<div class="set-group-head"><h4>MCP servers</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
<div class="set-row" data-search="mcp server sync enable claude codex gemini opencode antigravity">
<div class="set-row-text">
<span class="set-row-label">Enable MCP server sync</span>
<span class="set-row-desc">Adds a control that copies MCP servers between your enabled CLIs by writing their own config files. Off by default: this changes other tools' configuration, not just Codeman's.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsMcpSync" onchange="app.applyMcpSyncVisibility()"><span class="slider"></span></label>
</div>
<div class="set-row" id="mcpSyncActionRow" style="display:none" data-search="mcp server sync preview">
<div class="set-row-text">
<span class="set-row-label">Sync MCP servers across CLIs</span>
<span class="set-row-desc">Copies each installed, enabled CLI's MCP servers into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as <code>.codeman-bak</code> (overwritten by each sync). A config dir moved by the CLI's own env var (<code>CODEX_HOME</code>, <code>CLAUDE_CONFIG_DIR</code>, <code>XDG_CONFIG_HOME</code>, <code>GEMINI_CLI_HOME</code>) is followed as Codeman's server sees it; a per-session override is not.</span>
</div>
<span>
<button class="btn-toolbar btn-sm" id="mcpSyncPreviewBtn" onclick="app.mcpSync(false)">Preview</button>
<button class="btn-toolbar btn-sm btn-primary" id="mcpSyncApplyBtn" onclick="app.mcpSync(true)">Sync now</button>
</span>
</div>
<div id="mcpSyncResult" class="set-note" style="display:none"></div>
</div>
</div>
</section>
<!-- ══ Notifications ════════════════════════════════════════════ -->
@@ -2601,6 +2654,57 @@
</div>
</div>
</div>
<div class="set-group" id="webhookGroup" style="display:none">
<div class="set-group-head"><h4>Webhook (ntfy, Slack, Discord)</h4><span class="set-scope">server</span></div>
<div class="set-group-body">
<div class="set-row" data-search="webhook ntfy slack discord notification phone headless">
<div class="set-row-text">
<span class="set-row-label">Send alerts to a webhook</span>
<span class="set-row-desc">Posts the same events as push notifications (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any URL, so a server with no browser open can still reach your phone. The URL is a secret: it is stored on the server only and is never shown again once saved. On public ntfy.sh anyone who guesses the topic can read it, so pick a long random one.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="webhookEnabled"><span class="slider"></span></label>
</div>
<div class="set-row has-field">
<div class="set-row-text"><span class="set-row-label">Service</span></div>
<select id="webhookKind" class="set-select">
<option value="ntfy">ntfy</option>
<option value="slack">Slack</option>
<option value="discord">Discord</option>
<option value="generic">Generic JSON</option>
</select>
</div>
<div class="set-row has-field">
<div class="set-row-text">
<span class="set-row-label">Webhook URL</span>
<span class="set-row-desc" id="webhookUrlHint">Nothing saved yet.</span>
</div>
<input type="password" id="webhookUrl" class="set-input" autocomplete="off" spellcheck="false" placeholder="https://ntfy.sh/your-topic">
</div>
<div class="set-row has-field">
<div class="set-row-text">
<span class="set-row-label">Which events</span>
<span class="set-row-desc">"Needs attention" skips the routine "response complete" message.</span>
</div>
<select id="webhookScope" class="set-select">
<option value="attention">Needs attention</option>
<option value="all">Everything</option>
</select>
</div>
<div class="set-row">
<div class="set-row-text">
<span class="set-row-label">Save and test</span>
<span class="set-row-desc">The main Settings Save saves this group too. Send test saves pending edits first.</span>
</div>
<span>
<button class="btn-toolbar btn-sm btn-primary" id="webhookSaveBtn" onclick="app.saveWebhook()">Save</button>
<button class="btn-toolbar btn-sm" id="webhookTestBtn" onclick="app.testWebhook()">Send test</button>
<button class="btn-toolbar btn-sm" id="webhookClearBtn" onclick="app.clearWebhook()" style="display:none">Remove URL</button>
</span>
</div>
<div id="webhookResult" class="set-note" style="display:none" data-i18n-skip></div>
</div>
</div>
</section>
<!-- ══ Voice ════════════════════════════════════════════════════ -->
@@ -3738,10 +3842,14 @@
<script defer src="notification-manager.js"></script>
<script defer src="keyboard-accessory.js"></script>
<script defer src="input-cjk.js"></script>
<!-- Shows iOS Safari IME composition text inside the terminal. Must precede terminal-ui.js. -->
<script defer src="mobile-ime-preview.js"></script>
<!-- Forwards committed input events that xterm drops on Android/GBoard soft keyboards. Must precede terminal-ui.js. -->
<script defer src="terminal-keycode229-recovery.js"></script>
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
<script defer src="sanitize-html.js"></script>
<!-- Owner tab layout projection (grouped vertical rail); pure, read by app.js. -->
<script defer src="tab-layout-browser.js"></script>
<script defer src="app.js"></script>
<script defer src="tab-rail-resize.js"></script>
<script defer src="terminal-ui.js"></script>
+297
View File
@@ -0,0 +1,297 @@
/**
* @fileoverview In-terminal preview of IME composition text on iOS Safari.
*
* On iOS WebKit touch devices the text an IME is composing (Japanese, Chinese,
* Korean, dictation) is not visible inside the terminal until it commits, so
* the user types blind. The controller listens to the helper textarea's
* composition events and asks the caller to render the latest composition
* (`phase: 'provisional'`), coalesced to one render per animation frame and
* capped at 2048 characters. When xterm emits the committed text through
* onData, the caller hands it to `consumeTerminalData()`, which switches the
* preview to `phase: 'committed'` until something else shows the text: the
* local echo overlay or a prediction (`completeCommit`), authoritative
* terminal output (`noteAuthoritativeOutput`), or a 2 s fallback timer. The
* same 2 s bound applies while waiting for a commit that never reaches onData
* (the user deleted the whole composition), so a later unrelated chunk is never
* mistaken for it.
*
* Keydown ordering: xterm registers its textarea keydown listener in the
* capture phase inside terminal.open() and finalizes the composition there
* (CompositionHelper.keydown), emitting the commit through onData
* synchronously. The controller therefore observes keydown in the capture
* phase on an ANCESTOR (`keydownTarget`, the terminal element), which runs
* before any listener on the textarea itself, and finalizes on exactly the
* keys xterm does.
*
* VISUAL ONLY: the controller never sends, consumes or reorders input bytes,
* and every callback is wrapped so a failing render cannot block the wire.
* `isIosWebKitTouch()` gates creation; other platforms keep xterm's own
* composition view untouched.
*
* @dependency none (standalone IIFE; consumed by terminal-ui.js)
* @loadorder 5.52 (before app.js/terminal-ui.js, which create the controller)
*/
(function (global) {
'use strict';
const COMMITTED_VISUAL_TTL = 2000;
const PREVIEW_CAP = 2048;
// keyCodes on which xterm 6's CompositionHelper.keydown keeps composing
// (CapsLock, the IME "composition character", Shift/Ctrl/Alt). Any other
// keydown during a composition finalizes it.
const KEEP_COMPOSING_KEYCODES = new Set([20, 229, 16, 17, 18]);
const CONTROL_OR_LINE_BREAK = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
function isIosWebKitTouch(nav = navigator) {
const userAgent = String(nav && nav.userAgent ? nav.userAgent : '');
const platform = String(nav && nav.platform ? nav.platform : '');
const touchPoints = Number(nav && nav.maxTouchPoints ? nav.maxTouchPoints : 0);
const iosDevice = /iPhone|iPad|iPod/.test(userAgent);
const desktopIpad = platform === 'MacIntel' && touchPoints > 1;
return touchPoints > 0 && /AppleWebKit/.test(userAgent) && (iosDevice || desktopIpad);
}
function create(options) {
const textarea = options.textarea;
// Must be the textarea or an ancestor of it, so its capture listener runs
// before xterm's capture listener on the textarea.
const keydownTarget = options.keydownTarget || textarea;
const render = typeof options.render === 'function' ? options.render : function () {};
const clear = typeof options.clear === 'function' ? options.clear : function () {};
const onCommit = typeof options.onCommit === 'function' ? options.onCommit : function () {};
const scheduleFrame = options.scheduleFrame || global.requestAnimationFrame.bind(global);
const cancelFrame = options.cancelFrame || global.cancelAnimationFrame.bind(global);
const setTimer = options.setTimer || global.setTimeout.bind(global);
const clearTimer = options.clearTimer || global.clearTimeout.bind(global);
let generation = 0;
let composing = false;
let awaitingCommit = false;
let committed = false;
let latestValue = '';
let renderPhase = null;
let frameToken = null;
let timerToken = null;
let finalizedByKeydown = false;
let destroyed = false;
let invokingClear = false;
function safely(callback, ...args) {
try {
return callback(...args);
} catch (_error) {
return undefined;
}
}
function cancelScheduledFrame() {
const token = frameToken;
frameToken = null;
if (token && token.id !== undefined) safely(cancelFrame, token.id);
}
function cancelCommittedTimer() {
const token = timerToken;
timerToken = null;
if (token && token.id !== undefined) safely(clearTimer, token.id);
}
function clearVisual() {
if (invokingClear) return;
invokingClear = true;
safely(clear);
invokingClear = false;
}
function cleanup() {
generation += 1;
cancelScheduledFrame();
cancelCommittedTimer();
composing = false;
awaitingCommit = false;
committed = false;
latestValue = '';
renderPhase = null;
finalizedByKeydown = false;
clearVisual();
}
function scheduleLatestPreview(phase) {
if (destroyed) return;
renderPhase = phase;
if (frameToken) return;
const token = { generation, id: undefined };
frameToken = token;
const callback = function () {
if (destroyed || frameToken !== token || token.generation !== generation || renderPhase === null) return;
frameToken = null;
const value = latestValue.slice(0, PREVIEW_CAP);
const phaseToRender = renderPhase;
safely(render, { text: value, phase: phaseToRender });
};
const id = safely(scheduleFrame, callback);
if (frameToken === token) {
if (id === undefined) frameToken = null;
else token.id = id;
}
}
function beginComposition() {
cleanup();
if (destroyed) return;
composing = true;
}
function updateComposition(event) {
if (!composing) return;
latestValue = event.data == null ? '' : String(event.data);
scheduleLatestPreview('provisional');
}
function onComposingInput(event) {
if (!event.isComposing) return;
updateComposition({ data: event.data == null ? textarea.value : event.data });
}
function armFallbackTimer(isCurrent) {
const token = { generation, id: undefined };
timerToken = token;
const callback = function () {
if (destroyed || timerToken !== token || token.generation !== generation || !isCurrent()) return;
timerToken = null;
cleanup();
};
const id = safely(setTimer, callback, COMMITTED_VISUAL_TTL);
if (timerToken === token) {
if (id === undefined) {
timerToken = null;
return false;
}
token.id = id;
}
return true;
}
function finalizeComposition(value, fromKeydown) {
if (!composing) return;
composing = false;
awaitingCommit = true;
committed = false;
finalizedByKeydown = fromKeydown;
latestValue = value == null ? latestValue : String(value);
scheduleLatestPreview('provisional');
// A commit that never reaches onData (the composition was deleted, so
// xterm emits nothing) must not leave the controller waiting forever.
cancelCommittedTimer();
const owner = generation;
armFallbackTimer(function () {
return awaitingCommit && generation === owner;
});
}
function onCompositionEnd(event) {
if (finalizedByKeydown) {
finalizedByKeydown = false;
return;
}
finalizeComposition(event.data, false);
}
// Mirrors CompositionHelper.keydown in @xterm/xterm 6.0.0
// (src/browser/input/CompositionHelper.ts:94-108): while composing, keyCode
// 20/229 and 16/17/18 keep the composition open and every other keyCode
// finalizes it. `isComposing` and `key` are deliberately not consulted,
// because xterm does not consult them.
function onKeydown(event) {
if (keydownTarget !== textarea && event.target !== textarea) return;
if (!composing || KEEP_COMPOSING_KEYCODES.has(event.keyCode)) return;
finalizeComposition(latestValue, true);
}
function reset() {
if (destroyed) return;
cleanup();
}
function consumeTerminalData(data) {
if (
destroyed ||
!awaitingCommit ||
typeof data !== 'string' ||
data.length === 0 ||
CONTROL_OR_LINE_BREAK.test(data)
) {
return false;
}
generation += 1;
const owner = generation;
cancelScheduledFrame();
cancelCommittedTimer();
composing = false;
awaitingCommit = false;
committed = true;
latestValue = data;
renderPhase = 'committed';
safely(onCommit, data);
if (destroyed || generation !== owner || !committed) return true;
scheduleLatestPreview('committed');
if (destroyed || generation !== owner || !committed) return true;
const armed = armFallbackTimer(function () {
return committed;
});
if (!armed && !destroyed && generation === owner && committed) cleanup();
return true;
}
function completeCommit(result) {
if (destroyed || !result || result.predicted !== true || !committed) return;
cleanup();
}
function noteAuthoritativeOutput() {
if (destroyed || !committed) return;
cleanup();
}
const listeners = [
[textarea, 'compositionstart', beginComposition],
[textarea, 'compositionupdate', updateComposition],
[textarea, 'input', onComposingInput],
[textarea, 'compositionend', onCompositionEnd],
[keydownTarget, 'keydown', onKeydown, true],
[textarea, 'blur', reset],
];
for (const [target, type, listener, capture] of listeners) target.addEventListener(type, listener, capture);
function destroy() {
if (destroyed) return;
destroyed = true;
for (const [target, type, listener, capture] of listeners) target.removeEventListener(type, listener, capture);
cleanup();
}
return {
consumeTerminalData,
completeCommit,
noteAuthoritativeOutput,
reset,
destroy,
get state() {
return {
generation,
composing,
awaitingCommit,
committed,
latest: latestValue,
framePending: frameToken !== null,
timerPending: timerToken !== null,
};
},
};
}
global.MobileImePreview = { create, isIosWebKitTouch };
})(globalThis);
+2
View File
@@ -1038,6 +1038,7 @@ Object.assign(CodemanApp.prototype, {
const ralphGlobalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(config.caseName), ralphGlobalSettings);
const effort = this.getEffortSetting(ralphGlobalSettings);
const advisorModel = this.getAdvisorSetting(ralphGlobalSettings);
const res = await fetch('/api/ralph-loop/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1050,6 +1051,7 @@ Object.assign(CodemanApp.prototype, {
planItems: enabledItems?.length ? enabledItems : undefined,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(effort ? { effort } : {}),
...(advisorModel ? { advisorModel } : {}),
}),
});
const data = await res.json();
+27 -11
View File
@@ -153,7 +153,6 @@ Object.assign(CodemanApp.prototype, {
const edges = this._collectLineageEdges();
if (edges.length === 0) return;
this._lineageEdgeCount = edges.length;
if (!rects) rects = new Map();
// PHASE 1 — reads.
@@ -162,19 +161,35 @@ Object.assign(CodemanApp.prototype, {
const stripRect = strip.getBoundingClientRect();
const orientation =
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
// A session hidden inside a collapsed group of the grouped rail has no row
// to anchor to, so its end of the arc moves to that group's header (a
// "proxied" endpoint, drawn quieter). Two endpoints proxied to the SAME
// header would be an arc from a row to itself: skipped.
const resolveEndpoint = (id) => {
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
const groupId = this._hiddenTabGroupByRef?.get('session:' + id);
if (!groupId) return { key: 'tab:' + id, element: null, proxied: false };
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
return { key: 'group:' + groupId, element: header, proxied: !!header };
};
const resolvedEdges = [];
for (const edge of edges) {
for (const id of [edge.parentId, edge.childId]) {
const key = 'tab:' + id;
if (rects.has(key)) continue;
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
rects.set(key, tab ? tab.getBoundingClientRect() : null);
const parentEndpoint = resolveEndpoint(edge.parentId);
const childEndpoint = resolveEndpoint(edge.childId);
if (parentEndpoint.key === childEndpoint.key) continue;
resolvedEdges.push({ edge, parentEndpoint, childEndpoint });
for (const endpoint of [parentEndpoint, childEndpoint]) {
if (rects.has(endpoint.key)) continue;
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
}
}
this._lineageEdgeCount = resolvedEdges.length;
// PHASE 2 — writes, from the cache only.
for (const edge of edges) {
const parentRect = rects.get('tab:' + edge.parentId);
const childRect = rects.get('tab:' + edge.childId);
for (const { edge, parentEndpoint, childEndpoint } of resolvedEdges) {
const parentRect = rects.get(parentEndpoint.key);
const childRect = rects.get(childEndpoint.key);
if (!parentRect || !childRect) continue;
const geom = compute({
@@ -191,7 +206,8 @@ Object.assign(CodemanApp.prototype, {
// The working class marches the dashes, so an active worker is visible along
// the line itself. `status` is the CHILD's, which is the interesting end.
const working = edge.status === 'working' ? ' lineage-line--working' : '';
line.setAttribute('class', 'connection-line lineage-line' + working);
const proxied = parentEndpoint.proxied || childEndpoint.proxied;
line.setAttribute('class', 'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : ''));
// The PARENT's colour rides a CSS custom property so the stylesheet keeps owning
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
// Every arc out of one tab shares it — see _lineageColorFor().
@@ -211,7 +227,7 @@ Object.assign(CodemanApp.prototype, {
// Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
// works, so the two have to be changed together.
dot.setAttribute('r', '3.5');
dot.setAttribute('class', 'lineage-line-dot' + working);
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
dot.setAttribute('data-child-tab', edge.childId);
if (color) dot.style.setProperty('--lineage-color', color);
svg.appendChild(dot);
+13
View File
@@ -169,6 +169,17 @@ Object.assign(CodemanApp.prototype, {
return valid.includes(effort) ? effort : undefined;
},
/**
* Resolve the advisor model for new Claude sessions from global settings.
* Returns 'fable' | 'opus' | 'sonnet', or undefined (= leave it to the CLI's own
* /advisor choice). Sent as the `advisorModel` payload field; the backend merges it
* into the launch's `claude --settings` JSON, so /advisor still switches it in-session.
*/
getAdvisorSetting(globalSettings) {
const advisor = globalSettings?.claudeAdvisorModel;
return ['fable', 'opus', 'sonnet'].includes(advisor) ? advisor : undefined;
},
// ═══════════════════════════════════════════════════════════════
// Quick Start
// ═══════════════════════════════════════════════════════════════
@@ -1965,6 +1976,7 @@ Object.assign(CodemanApp.prototype, {
const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings);
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
const effort = this.getEffortSetting(globalSettings);
const advisorModel = this.getAdvisorSetting(globalSettings);
// Explicit Claude Model choice (App Settings) wins over the legacy 1M Opus
// toggles; both flow as `modelOverride` → the case's .claude/settings.local.json
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
@@ -1980,6 +1992,7 @@ Object.assign(CodemanApp.prototype, {
workingDir, name,
...(hasEnvOverrides ? { envOverrides } : {}),
...(effort ? { effort } : {}),
...(advisorModel ? { advisorModel } : {}),
...(modelOverride !== undefined ? { modelOverride } : {}),
})
}).then(r => r.json())
+275 -8
View File
@@ -416,6 +416,13 @@ Object.assign(CodemanApp.prototype, {
// .checked fires no onchange, so the list's visibility (and lazy load)
// needs an explicit sync on every open, not just a save.
this.applyCliManagementVisibility();
// MCP server sync: synced, default OFF; same explicit-sync reasoning as above.
// The routes read the SAVED setting, so remember what it was on open: switching it on
// here does nothing server-side until Save (see mcpSync()).
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn;
this.applyMcpSyncVisibility();
this.loadWebhook();
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
@@ -527,6 +534,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true;
document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? '';
document.getElementById('appSettingsClaudeAdvisor').value = settings.claudeAdvisorModel ?? '';
// CPU Priority settings
const niceSettings = settings.nice || {};
document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false;
@@ -627,6 +635,7 @@ Object.assign(CodemanApp.prototype, {
this._syncSettingsChips();
this._syncModelCards();
this._syncEffortSegment();
this._syncAdvisorSegment();
// Back to the top of the document (one scroll, not a tab reset). Updates is
// first now: the version this install is running, and whether a newer one is
// waiting, are the two things worth seeing before any preference. The rest of
@@ -718,6 +727,7 @@ Object.assign(CodemanApp.prototype, {
if (!modal || !doc || typeof modal.querySelectorAll !== 'function') return;
this._buildModelCards();
this._buildEffortSegment();
this._buildAdvisorSegment();
// Rebuilt on every open: admin-ui.js appends its Users entry to the rail
// after the first open, and the menu must not drift from the rail.
this._buildSettingsJumpMenu();
@@ -993,8 +1003,28 @@ Object.assign(CodemanApp.prototype, {
},
_buildEffortSegment() {
const select = document.getElementById('appSettingsThinkingEffort');
const seg = document.getElementById('appSettingsEffortSegment');
this._buildSelectSegment('appSettingsThinkingEffort', 'appSettingsEffortSegment');
},
_syncEffortSegment() {
this._syncSelectSegment('appSettingsThinkingEffort', 'appSettingsEffortSegment');
},
_buildAdvisorSegment() {
this._buildSelectSegment('appSettingsClaudeAdvisor', 'appSettingsAdvisorSegment');
},
_syncAdvisorSegment() {
this._syncSelectSegment('appSettingsClaudeAdvisor', 'appSettingsAdvisorSegment');
},
/**
* Build a radio segment as a view over a hidden <select>, which stays the single
* source of truth for load/save (the same contract as the model cards).
*/
_buildSelectSegment(selectId, segId) {
const select = document.getElementById(selectId);
const seg = document.getElementById(segId);
if (!select || !seg || seg.dataset.built === '1' || !select.options) return;
seg.innerHTML = '';
[...select.options].forEach(opt => {
@@ -1005,16 +1035,16 @@ Object.assign(CodemanApp.prototype, {
btn.textContent = opt.textContent;
btn.addEventListener('click', () => {
select.value = opt.value;
this._syncEffortSegment();
this._syncSelectSegment(selectId, segId);
});
seg.appendChild(btn);
});
seg.dataset.built = '1';
},
_syncEffortSegment() {
const select = document.getElementById('appSettingsThinkingEffort');
const seg = document.getElementById('appSettingsEffortSegment');
_syncSelectSegment(selectId, segId) {
const select = document.getElementById(selectId);
const seg = document.getElementById(segId);
if (!select || !seg) return;
seg.querySelectorAll('button').forEach(btn => {
const on = btn.dataset.value === (select.value || '');
@@ -1114,6 +1144,226 @@ Object.assign(CodemanApp.prototype, {
this._updateCheck = null;
},
/**
* Settings → Terminal & Input → Key tester: prints what the browser reports for each key event.
* Read-only and local; it never reaches a session. keypress is shown on purpose: that event is
* why a Shift-only Enter used to submit (xterm drops Ctrl/Alt keypresses, not Shift ones).
*/
keyTesterEvent(ev) {
const log = document.getElementById('keyTesterLog');
if (!log) return;
// Never preventDefault on keydown: that suppresses the keypress this panel exists to show.
// The field is readonly, so nothing is typed into it either way.
const mods = ['ctrlKey', 'shiftKey', 'altKey', 'metaKey'].filter((m) => ev[m]).map((m) => m.replace('Key', ''));
const line =
`${ev.type.padEnd(8)} key=${JSON.stringify(ev.key)} code=${ev.code || '-'} ` +
`mods=${mods.join('+') || 'none'}` +
(ev.type === 'keypress' ? ` charCode=${ev.charCode}` : '') +
(ev.repeat ? ' (repeat)' : '');
const lines = (log.textContent ? log.textContent.split('\n') : []).concat(line);
log.textContent = lines.slice(-14).join('\n');
log.style.display = 'block';
},
/**
* MCP sync is opt-in (`mcpSyncEnabled`): with the flag off the action row is hidden rather than
* shown disabled, because both endpoints would only answer 403. Called on open and from the
* checkbox's own onchange (assigning .checked fires no change event).
*/
applyMcpSyncVisibility() {
const on = document.getElementById('appSettingsMcpSync')?.checked ?? false;
const row = document.getElementById('mcpSyncActionRow');
if (row) row.style.display = on ? '' : 'none';
const out = this.$('mcpSyncResult');
if (!on && out) { out.style.display = 'none'; out.innerHTML = ''; }
this._applyMcpSyncAdminGate();
},
/**
* Both /api/mcp-sync verbs are admin-only in multi-user mode (they write files in the server
* user's home), so a non-admin gets no MCP group at all, switch included, the same way
* _applyCliManagementAdminGate hides the CLI list. Also wired to `codeman:me`, because
* `window.__codemanUser`'s real role can resolve after settings were opened once.
*/
_applyMcpSyncAdminGate() {
const group = document.getElementById('mcpSyncGroup');
if (!group) return;
const me = window.__codemanUser || {};
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
},
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
async mcpSync(apply) {
const out = this.$('mcpSyncResult');
const show = (html) => {
if (out) { out.style.display = 'block'; out.innerHTML = html; }
};
// Switched on in this modal but not saved yet: the routes would only answer "disabled".
if (!this._mcpSyncSavedOn) {
show('Save settings to turn MCP sync on first, then reopen Settings to preview or sync.');
return;
}
if (apply && !confirm('Add missing MCP servers to every installed, enabled CLI\'s config file? Env values and headers on those servers are copied too.')) return;
show('Working…');
const res = apply ? await this._apiPost('/api/mcp-sync', {}) : await this._api('/api/mcp-sync');
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
show(escapeHtml(body?.error || 'MCP sync failed.'));
return;
}
const data = body.data;
const rows = data.targets.map((t) => {
if (t.status === 'absent') return `<li><b>${escapeHtml(t.label)}</b>: not installed, skipped</li>`;
if (t.status === 'skipped') return `<li><b>${escapeHtml(t.label)}</b>: not touched (${escapeHtml(t.error || 'config location unknown')})</li>`;
if (t.status === 'unreadable') return `<li><b>${escapeHtml(t.label)}</b>: not touched, file can't be read safely (${escapeHtml(t.error || 'unreadable')})</li>`;
if (t.status === 'failed') return `<li><b>${escapeHtml(t.label)}</b>: failed (${escapeHtml(t.error || 'error')}); the file may be unchanged</li>`;
const verb = data.applied ? 'added' : 'would add';
const parts = [t.added.length ? `${verb} ${t.added.map(escapeHtml).join(', ')}` : 'up to date'];
if (t.skipped.length) parts.push(`can't express ${t.skipped.map(escapeHtml).join(', ')}`);
const count = `${t.servers.length} server${t.servers.length === 1 ? '' : 's'}`;
return `<li><b>${escapeHtml(t.label)}</b> (${count}): ${parts.join('; ')}</li>`;
});
const conflicts = data.conflicts.length
? `<p>Defined differently across CLIs (each existing definition is kept; the first CLI's is copied where the name is missing): ${data.conflicts.map(escapeHtml).join(', ')}</p>`
: '';
const disabled = data.disabled?.length
? `<p>Switched off in their own CLI, so not copied: ${data.disabled.map(escapeHtml).join(', ')}</p>`
: '';
const unsupported = data.unsupported?.length
? `<p>No MCP config support for: ${data.unsupported.map(escapeHtml).join(', ')}</p>`
: '';
show(`<ul>${rows.join('')}</ul>${conflicts}${disabled}${unsupported}`);
},
/**
* Webhook notifications (Settings → Notifications). Server-side config behind /api/webhook, not a
* settings-payload field: the URL is a secret, so it never round-trips through settings.json or
* this page. The URL box is write-only; the status line shows scheme + host only.
*
* Three ways to save, one PUT: the group's own Save, Send test (saves pending edits first, so it
* never tests the old URL while the box shows a new one), and the modal's main Save, which calls
* saveWebhook() beside the settings PUT the same way it saves the model config
* (saveModelConfigFromSettings). `_webhookLoaded` is what loadWebhook() put on screen, so
* `_webhookPending()` can tell an edited group from an untouched one.
*/
_webhookSay(text, bad = false) {
const out = document.getElementById('webhookResult');
if (!out) return;
out.textContent = text;
out.style.display = text ? 'block' : 'none';
out.style.color = bad ? 'var(--danger, #e5534b)' : '';
},
async loadWebhook() {
const group = document.getElementById('webhookGroup');
if (!group) return;
const res = await this._api('/api/webhook');
if (!res || !res.ok) {
this._webhookLoaded = null;
group.style.display = 'none'; // not an admin in multi-user mode, or the server predates the route
return;
}
let body = null;
try { body = await res.json(); } catch { /* leave hidden */ }
if (!body || body.success === false) { this._webhookLoaded = null; group.style.display = 'none'; return; }
const d = body.data;
group.style.display = '';
document.getElementById('webhookEnabled').checked = d.enabled === true;
document.getElementById('webhookKind').value = d.kind;
document.getElementById('webhookScope').value = d.scope;
const url = document.getElementById('webhookUrl');
url.value = '';
url.placeholder = d.hasUrl ? 'Saved. Paste a new URL to replace it' : 'https://ntfy.sh/your-topic';
document.getElementById('webhookUrlHint').textContent = d.hasUrl ? `Saved: ${d.urlMasked}` : 'Nothing saved yet.';
const clearBtn = document.getElementById('webhookClearBtn');
if (clearBtn) clearBtn.style.display = d.hasUrl ? '' : 'none';
// Read back from the controls, so a value the <select> does not offer compares as what is shown.
this._webhookLoaded = {
enabled: document.getElementById('webhookEnabled').checked,
kind: document.getElementById('webhookKind').value,
scope: document.getElementById('webhookScope').value,
};
if (d.lastResult) {
const when = new Date(d.lastResult.at).toLocaleString();
this._webhookSay(
d.lastResult.ok ? `Last delivery succeeded (${when}).` : `Last delivery failed (${when}): ${d.lastResult.error}`,
!d.lastResult.ok
);
} else {
this._webhookSay('');
}
},
/** True when the visible webhook group differs from what loadWebhook() last showed. */
_webhookPending() {
const group = document.getElementById('webhookGroup');
const loaded = this._webhookLoaded;
if (!group || group.style.display === 'none' || !loaded) return false;
return (
document.getElementById('webhookUrl').value.trim() !== '' ||
document.getElementById('webhookEnabled').checked !== loaded.enabled ||
document.getElementById('webhookKind').value !== loaded.kind ||
document.getElementById('webhookScope').value !== loaded.scope
);
},
/** PUT the group's state. Resolves to '' on success, else the error (also shown in the group). */
async saveWebhook() {
const payload = {
enabled: document.getElementById('webhookEnabled').checked,
kind: document.getElementById('webhookKind').value,
scope: document.getElementById('webhookScope').value,
};
const url = document.getElementById('webhookUrl').value.trim();
if (url) payload.url = url; // blank = keep the saved one (Remove URL is the way to clear it)
const res = await this._api('/api/webhook', { method: 'PUT', body: payload });
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
const error = body?.error || 'Could not save the webhook.';
this._webhookSay(error, true);
return error;
}
await this.loadWebhook();
this._webhookSay('Saved.');
return '';
},
/** Delete the saved URL from the server (the API clears on `url: ""`), which also turns the channel off. */
async clearWebhook() {
if (!confirm('Remove the saved webhook URL from the server? Webhook alerts stop until you save a new one.')) return;
const res = await this._api('/api/webhook', { method: 'PUT', body: { url: '', enabled: false } });
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
this._webhookSay(body?.error || 'Could not remove the webhook URL.', true);
return;
}
await this.loadWebhook();
this._webhookSay('Webhook URL removed.');
},
async testWebhook() {
const btn = document.getElementById('webhookTestBtn');
if (btn) btn.disabled = true;
try {
if (this._webhookPending() && (await this.saveWebhook())) return; // the save's error is already shown
this._webhookSay('Sending…');
const res = await this._apiPost('/api/webhook/test', {});
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
this._webhookSay(body?.error || 'Could not send the test.', true);
return;
}
const r = body.data;
this._webhookSay(r.ok ? 'Test sent. Check your phone or channel.' : `Delivery failed: ${r.error}`, !r.ok);
} finally {
if (btn) btn.disabled = false;
}
},
_setUpdateResult(html) {
const el = this.$('updateResult');
if (el) { el.style.display = 'block'; el.innerHTML = html; }
@@ -2159,6 +2409,7 @@ Object.assign(CodemanApp.prototype, {
approvalsInboxEnabled: document.getElementById('appSettingsApprovalsInbox').checked,
customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked,
cliManagementEnabled: document.getElementById('appSettingsCliManagement').checked,
mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked,
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
@@ -2214,6 +2465,7 @@ Object.assign(CodemanApp.prototype, {
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
remoteAutoReconnect: document.getElementById('appSettingsRemoteAutoReconnect').checked,
thinkingEffort: document.getElementById('appSettingsThinkingEffort').value,
claudeAdvisorModel: document.getElementById('appSettingsClaudeAdvisor').value,
// CPU Priority settings
nice: {
enabled: document.getElementById('appSettingsNiceEnabled').checked,
@@ -2431,6 +2683,7 @@ Object.assign(CodemanApp.prototype, {
sessionLineageLines: _sll,
...serverSettings
} = settings;
let webhookError = '';
try {
const res = await this._apiPut('/api/settings', {
...serverSettings,
@@ -2455,7 +2708,16 @@ Object.assign(CodemanApp.prototype, {
// Save model configuration separately
await this.saveModelConfigFromSettings();
this.showToast('Settings saved', 'success');
// The webhook is server state in its own 0600 file (its URL is a secret, kept out of
// settings.json), so like the model config above it is saved beside the settings PUT, not in
// it. Only when the group was edited: an untouched group must not re-PUT. A refusal (bad URL,
// enabled with no URL) keeps the modal open below, with the pasted URL still in the box.
webhookError = this._webhookPending() ? await this.saveWebhook() : '';
if (webhookError) {
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
} else {
this.showToast('Settings saved', 'success');
}
// Show tunnel-specific feedback if toggled on
if (settings.tunnelEnabled) {
@@ -2466,7 +2728,11 @@ Object.assign(CodemanApp.prototype, {
this.showToast('Settings saved locally', 'warning');
}
this.closeAppSettings();
if (webhookError) {
document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' });
} else {
this.closeAppSettings();
}
// Voice availability is a server-side answer, so re-probe after a save:
// otherwise the mic keeps using the pre-save provider until the next reload.
@@ -4106,4 +4372,5 @@ Object.assign(CodemanApp.prototype, {
document.addEventListener?.('codeman:me', () => {
window.app?._applyCustomModelAdminGate?.();
window.app?._applyCliManagementAdminGate?.();
window.app?._applyMcpSyncAdminGate?.();
});
+124 -3
View File
@@ -460,6 +460,31 @@ textarea:focus-visible {
font-size: 16px !important; /* prevent iOS auto-zoom on focus */
}
.xterm-helpers .codeman-ime-preview {
position: absolute;
left: var(--xterm-helper-left, 0px);
top: var(--xterm-helper-top, 0px);
z-index: 6;
pointer-events: none;
white-space: pre;
color: var(--terminal-foreground, var(--text, #fff));
font-family: var(--font-mono, monospace);
font-size: 14px;
font-weight: 400;
font-style: normal;
line-height: 1.2;
height: 1.2em;
}
.xterm-helpers .codeman-ime-preview[data-phase='provisional'] {
text-decoration: underline;
text-decoration-style: dotted;
}
.touch-device .xterm-helpers.codeman-ime-preview-owned .composition-view.active {
display: none;
}
/* Session tab focus */
.session-tab:focus-visible {
outline: 2px solid var(--accent);
@@ -634,6 +659,83 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-info {
min-width: 0;
}
/* --- Grouped rail (owner tab layout) ---------------------------------- */
/* Only rendered when the owner has at least one group (tab-layout-browser.js).
Each group's rows sit in their OWN flex column, so the activity sort's inline
`order` (and web tabs' `order: 9999`) rank rows within a group, never across. */
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group,
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-refs {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 2px;
min-width: 0;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group + .tab-layout-group {
margin-top: 4px;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header {
/* Positioned so a collapsed header can carry the tab alert ring (::before). */
position: relative;
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
padding: 4px 6px 2px;
border: 0;
border-radius: 4px;
color: var(--text-muted);
background: transparent;
font: inherit;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-align: left;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle {
cursor: pointer;
user-select: none;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:hover {
color: var(--text);
background: var(--bg-hover);
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.tab-layout-group-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-layout-group-count {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
opacity: 0.75;
}
.tab-layout-group-chevron::before {
content: '\25BE';
display: inline-block;
}
/* Keyed on the section, not aria-expanded: a group with no open rows is a tree
leaf with no aria-expanded at all, and its chevron must still show its state. */
.tab-layout-group--collapsed > .tab-layout-group-toggle .tab-layout-group-chevron::before {
content: '\25B8';
}
html[data-tab-orientation='vertical'] .header-right {
margin-left: auto;
}
@@ -1707,7 +1809,9 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
line-height: 1;
color: var(--text-muted);
overflow: hidden;
cursor: default;
/* A control, like its gear/detach siblings: the pointer must not read as
"nothing here" over the one button that closes the session. */
cursor: pointer;
border-radius: 3px;
/* Performance: use transform for GPU acceleration */
transition: opacity 0.05s ease-out, width 0.05s ease-out, padding 0.05s ease-out;
@@ -1760,7 +1864,8 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
The overlay paints above the tab's inline content (positioned vs flow), which
is fine at these alphas and is exactly what keeps it visible over the active
tab's opaque-ish background. */
.session-tabs .session-tab.tab-alert-action::before {
.session-tabs .session-tab.tab-alert-action::before,
.session-tabs .tab-layout-group-header.tab-alert-action::before {
content: '';
position: absolute;
inset: -2px;
@@ -1785,7 +1890,8 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
box-shadow: 0 0 6px rgba(239, 68, 68, 0.7);
}
.session-tabs .session-tab.tab-alert-idle::before {
.session-tabs .session-tab.tab-alert-idle::before,
.session-tabs .tab-layout-group-header.tab-alert-idle::before {
content: '';
position: absolute;
inset: -2px;
@@ -10510,6 +10616,21 @@ kbd {
drop-shadow(0 0 9px var(--lineage-color, var(--session-blue, #2b8fd9)));
}
/* An arc re-anchored to a collapsed group's header (its row is hidden): quieter
and dotted, so it reads as "somewhere in there" rather than a real row. */
.connection-line.lineage-line--proxied {
stroke-dasharray: 2 7;
opacity: 0.46;
}
.connection-line.lineage-line--proxied.lineage-line--working {
opacity: 0.62;
}
.lineage-line-dot.lineage-line-dot--proxied {
opacity: 0.46;
}
/* The child end marches while that worker is actually working, so the line
itself carries the signal. Motion is opt-out-able at the OS level. */
@media (prefers-reduced-motion: no-preference) {
+354
View File
@@ -0,0 +1,354 @@
/**
* @fileoverview Read-only browser projection of the owner tab layout.
*
* `GET /api/tab-layout` returns the owner's named tab GROUPS (`src/tab-layout.ts`
* is the server model). Browser assets cannot import that TypeScript, so this
* module is a small, dependency-free mirror that owns three things:
*
* 1. Projection: which live sessions and open web tabs land in which group,
* and which rows a collapsed group hides.
* 2. Rendering: the grouped markup for the vertical tab rail. Rows themselves
* are rendered by the caller (app.js, webview-tabs.js), so a grouped row is
* byte-identical to the flat rail's row.
* 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed
* read degrades to the flat rail with a capped, backed-off retry.
*
* The server stays the only authority for layout content. Collapse is a
* per-device view preference and lives in localStorage only.
*
* Grouped rendering is opt-in by construction: a layout with no groups (every
* owner until they create one) projects to `null`, and the caller keeps the flat
* rail exactly as it was.
*
* @dependency none
* @loadorder 5.9 (before app.js, which reads window.CodemanTabLayout)
*/
(function initCodemanTabLayout(global) {
'use strict';
const COLLAPSED_STORAGE_KEY = 'codeman:tab-groups-collapsed';
const refKey = (ref) => `${ref.kind}:${ref.id}`;
const validRef = (ref) =>
!!ref && (ref.kind === 'session' || ref.kind === 'webview') && typeof ref.id === 'string' && ref.id.length > 0;
const asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []);
const stableIds = (value) => [...new Set(asIds(value))];
const copyRefs = (value) =>
Array.isArray(value) ? value.filter(validRef).map((r) => ({ kind: r.kind, id: r.id })) : [];
/**
* Defensive copy of a server layout. Unknown fields are dropped, so a newer
* server adding model fields cannot leak half-understood state into the view.
*/
function normalizeLayout(value) {
if (!value || typeof value !== 'object') throw new Error('Invalid tab layout');
const groups = Array.isArray(value.groups) ? value.groups : [];
return {
version: Number.isSafeInteger(value.version) && value.version >= 0 ? value.version : 0,
groups: groups
.filter((group) => group && typeof group.id === 'string' && group.id.length > 0)
.map((group) => ({
id: group.id,
name: typeof group.name === 'string' ? group.name : '',
refs: copyRefs(group.refs),
})),
ungrouped: copyRefs(value.ungrouped),
};
}
function hasGroups(layout) {
return !!layout && Array.isArray(layout.groups) && layout.groups.length > 0;
}
/** Stored collapse ids, or null when the stored value is not a JSON array. */
function parseCollapsedIds(raw) {
if (raw === null) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? stableIds(parsed) : null;
} catch (_error) {
return null;
}
}
/**
* Read the per-device collapse ids. `ok: false` means the STORE failed (a read
* or write threw), and the caller then keeps every group expanded. A malformed
* VALUE is not a store failure: it reads as "nothing collapsed" and is
* rewritten, or a shape left behind by another build (a rollback) would leave
* collapse disabled on this device for good.
*/
function loadCollapsedGroupIds(storage, validGroupIds) {
try {
const parsed = parseCollapsedIds(storage.getItem(COLLAPSED_STORAGE_KEY));
const loaded = parsed || [];
if (validGroupIds === undefined) return { ids: loaded, ok: true };
// Garbage-collect ids of groups that no longer exist, so a deleted group's
// id cannot silently collapse a future group that reuses it.
const valid = new Set(stableIds(validGroupIds));
const kept = loaded.filter((id) => valid.has(id));
if (!parsed || kept.length !== loaded.length) storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(kept));
return { ids: kept, ok: true };
} catch (_error) {
return { ids: [], ok: false };
}
}
function saveCollapsedGroupIds(storage, groupIds) {
const ids = stableIds(groupIds);
try {
storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(ids));
return { ids, ok: true };
} catch (_error) {
return { ids: [], ok: false };
}
}
/**
* Project a layout onto what is live in this browser.
*
* Every live session and open web tab appears exactly once: stored refs keep
* their group and stored order; anything the layout has not caught up with yet
* (a session created a moment ago, a web tab opened on this device only) is
* appended to the ungrouped section in the caller's order. Saved web tabs that
* are not open here are skipped, as are refs to sessions that are gone.
*
* A collapsed group hides its rows, EXCEPT the highlighted one (the active web
* tab, else the active session), so selecting a hidden session by keyboard,
* palette or Alt+N never leaves the user with no visible selection.
*
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef }}
* null when the layout has no groups: the caller renders the flat rail
* unchanged. Each section lists the rows it shows (`refs`) and the rows its
* collapse hides (`hidden`); `sectionByRef` maps every placed row
* (`<kind>:<id>`) to its section id (null = Ungrouped), shown or hidden.
*/
function project(layoutInput, options = {}) {
if (!layoutInput) return null;
const layout = normalizeLayout(layoutInput);
if (!hasGroups(layout)) return null;
const liveSessionIds = stableIds(options.liveSessionIds);
const openWebviewIds = stableIds(options.openWebviewIds);
const live = new Set(liveSessionIds);
const open = new Set(openWebviewIds);
const collapsed = new Set(asIds(options.collapsedGroupIds));
const highlighted = options.activeWebviewId
? `webview:${options.activeWebviewId}`
: options.activeSessionId
? `session:${options.activeSessionId}`
: '';
const renderable = (ref) => (ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id));
const placed = new Set();
const visibleRefs = [];
const hiddenTabGroupByRef = {};
const sectionByRef = {};
const sections = [];
const place = (refs, sectionId, isCollapsed) => {
const shown = [];
const hidden = [];
let count = 0;
for (const ref of refs) {
const key = refKey(ref);
if (placed.has(key) || !renderable(ref)) continue;
placed.add(key);
sectionByRef[key] = sectionId;
count++;
const copy = { kind: ref.kind, id: ref.id };
if (isCollapsed && key !== highlighted) {
hiddenTabGroupByRef[key] = sectionId;
hidden.push(copy);
continue;
}
shown.push(copy);
visibleRefs.push(copy);
}
return { shown, hidden, count };
};
for (const group of layout.groups) {
const isCollapsed = collapsed.has(group.id);
const { shown, hidden, count } = place(group.refs, group.id, isCollapsed);
sections.push({ id: group.id, name: group.name, refs: shown, hidden, count, collapsed: isCollapsed });
}
const omissions = [
...liveSessionIds.map((id) => ({ kind: 'session', id })),
...openWebviewIds.map((id) => ({ kind: 'webview', id })),
];
const ungrouped = place([...layout.ungrouped, ...omissions], null, false);
if (ungrouped.count > 0) {
sections.push({
id: null,
name: '',
refs: ungrouped.shown,
hidden: [],
count: ungrouped.count,
collapsed: false,
});
}
return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef };
}
const ALERT_RANK = { action: 2, idle: 1 };
/**
* The most urgent alert behind each COLLAPSED header: `{ [groupId]: 'action' |
* 'idle' }` over the session rows the collapse hides. A shown row (the kept
* selection, any expanded group) draws its own alert, so it is not counted
* here. `alertOf(sessionId)` is the caller's tab alert lookup.
*/
function hiddenGroupAlerts(projection, alertOf) {
const result = {};
const sections = projection && Array.isArray(projection.sections) ? projection.sections : [];
for (const section of sections) {
if (section.id === null || !Array.isArray(section.hidden)) continue;
let best = null;
for (const ref of section.hidden) {
if (ref.kind !== 'session') continue;
const alert = alertOf(ref.id);
if (ALERT_RANK[alert] && (!best || ALERT_RANK[alert] > ALERT_RANK[best])) best = alert;
}
if (best) result[section.id] = best;
}
return result;
}
/**
* Everything that changes the grouped rail's STRUCTURE (which rows exist and
* where, the headers' names, what a collapse hides), as opposed to a row's own
* status/name/badges. The incremental render path only patches rows in place,
* so a change here forces a full rebuild.
*
* Deliberately NOT the layout version: the server bumps it on every session
* create/close and order PUT, and a bump that moves nothing visible must not
* cost every client a full tab-strip rebuild. `layout` is accepted for
* signature stability only.
*/
function structureKey(_layout, projection, collapsedGroupIds) {
if (!projection) return null;
return JSON.stringify({
collapsed: stableIds(collapsedGroupIds).sort(),
sections: projection.sections.map((section) => [
section.id,
section.name,
section.count,
section.refs.map(refKey),
(section.hidden || []).map(refKey),
]),
});
}
/**
* Grouped rail markup. `renderRef(ref)` returns one row's HTML ('' to skip it);
* `escapeHtml` is the caller's escaper. Group names are user content, so they
* are escaped and marked `data-i18n-skip`.
*
* The caller makes the list itself the `tree` and marks rows up as treeitems
* (app.js `_applyTabTreeSemantics`); this markup supplies the structure:
* - a named group's header is a level-1 `treeitem` carrying `aria-expanded`.
* Its rows are a sibling `group`, so the header OWNS it via `aria-owns`
* (the rows sit below the header visually, not inside it).
* - a COLLAPSED group owns nothing: the one row it still shows (the
* selection) is a level-1 sibling, never the child of a closed node.
* - a group with NO open rows is a leaf: no `aria-expanded`, no owned group,
* so it is not announced as an expanded parent of an empty group.
* - Ungrouped rows are level-1 items. Their "Ungrouped" heading is a visual
* divider only, hidden from assistive tech, and its rows are not a group.
*/
function renderProjection(projection, renderRef, escapeHtml) {
const sections = projection && Array.isArray(projection.sections) ? projection.sections : [];
return sections
.map((section, index) => {
const rows = section.refs.map((ref) => renderRef(ref)).join('');
if (section.id === null) {
return (
'<section class="tab-layout-group tab-layout-ungrouped" role="presentation" data-tab-group-id="">' +
`<div class="tab-layout-group-header tab-layout-ungrouped-header" aria-hidden="true"><span class="tab-layout-group-name">Ungrouped</span><span class="tab-layout-group-count">${section.count}</span></div>` +
`<div class="tab-layout-group-refs" role="presentation">${rows}</div></section>`
);
}
const id = escapeHtml(section.id);
const refsId = `tab-layout-group-refs-${index}`;
const nameId = `tab-layout-group-name-${index}`;
const leaf = section.count === 0;
const expanded = !section.collapsed && !leaf;
const expandedAttr = leaf ? '' : ` aria-expanded="${expanded ? 'true' : 'false'}"`;
return (
`<section class="tab-layout-group${section.collapsed ? ' tab-layout-group--collapsed' : ''}" role="presentation" data-tab-group-id="${id}">` +
`<div class="tab-layout-group-header tab-layout-group-toggle" role="treeitem" tabindex="-1" data-tab-group-header="${id}"${expandedAttr}${expanded ? ` aria-owns="${refsId}"` : ''} onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)">` +
'<span class="tab-layout-group-chevron" aria-hidden="true"></span>' +
`<span class="tab-layout-group-name" id="${nameId}" data-i18n-skip>${escapeHtml(section.name)}</span>` +
`<span class="tab-layout-group-count">${section.count}</span></div>` +
`<div class="tab-layout-group-refs" id="${refsId}" ${expanded ? `role="group" aria-labelledby="${nameId}"` : 'role="presentation"'}>${rows}</div></section>`
);
})
.join('');
}
/**
* Newest-wins layout loading. A response that was overtaken by a later load is
* dropped; a failure applies the fallback (the flat rail) and schedules ONE
* retry, replacing any retry already pending.
*
* Retries back off and stop: the delay doubles from `retryDelayMs` up to
* `maxRetryDelayMs`, and after `maxRetries` consecutive failures nothing more
* is scheduled (`scheduleRetry(fn, delayMs)`). The next outside load (an SSE
* reconnect re-runs init, a `tab:layoutChanged` re-reads) tries again, and
* any success resets the count.
*/
function createLoadCoordinator(options) {
const baseDelay = Number.isFinite(options.retryDelayMs) ? options.retryDelayMs : 5000;
const maxDelay = Number.isFinite(options.maxRetryDelayMs) ? options.maxRetryDelayMs : 60000;
const maxRetries = Number.isSafeInteger(options.maxRetries) ? options.maxRetries : 4;
let generation = 0;
let disposed = false;
let retryHandle = null;
let failures = 0;
const clearRetry = () => {
if (retryHandle !== null && options.cancelRetry) options.cancelRetry(retryHandle);
retryHandle = null;
};
const load = async () => {
if (disposed) return false;
const requestGeneration = ++generation;
clearRetry();
try {
const layout = await options.fetchLayout();
if (disposed || requestGeneration !== generation) return false;
failures = 0;
options.applyLayout(layout);
return true;
} catch (_error) {
if (disposed || requestGeneration !== generation) return false;
failures++;
options.applyFallback();
if (failures <= maxRetries) {
const delay = Math.min(maxDelay, baseDelay * 2 ** (failures - 1));
retryHandle = options.scheduleRetry(() => load(), delay);
}
return false;
}
};
return {
load,
dispose() {
disposed = true;
generation++;
clearRetry();
},
};
}
global.CodemanTabLayout = {
normalizeLayout,
hasGroups,
project,
hiddenGroupAlerts,
structureKey,
renderProjection,
createLoadCoordinator,
loadCollapsedGroupIds,
saveCollapsedGroupIds,
};
})(typeof window !== 'undefined' ? window : globalThis);
+82 -34
View File
@@ -77,11 +77,14 @@
this._bufferLoading = false;
this._bufferRefreshPending = false;
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
// `_liveQueue` is non-null exactly while a pull is replaying: live frames
// are held there with their arrival time instead of written under it.
// `_liveQueue` is non-null from the pull's response until its finally
// block: live frames are held there with their arrival time instead of
// written under the replay. `_markerOwed` is the "disconnected" marker a
// load still has to write (see _onSocketClosed()/_stampMarkerIfOwed()).
this._historyPullAt = 0;
this._historyPullUseless = false;
this._liveQueue = null;
this._markerOwed = false;
this._onWheel = null;
}
@@ -169,14 +172,17 @@
// session (this.sessionId), never the primary pane's
// activeSessionId, and has no local-echo overlay of its own to flush
// first (Pane B is deliberately plainer — see the fileoverview).
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
fetch(`/api/sessions/${this.sessionId}/send-key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
}).catch(() => {
/* Best-effort, matching this pane's tolerance elsewhere. */
});
// Swallow keypress/keyup too (xterm would send \r for a Shift-only keypress); only keydown sends.
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
if (ev.type === 'keydown') {
fetch(`/api/sessions/${this.sessionId}/send-key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
}).catch(() => {
/* Best-effort, matching this pane's tolerance elsewhere. */
});
}
return false;
}
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
@@ -301,18 +307,37 @@
}
// The socket's close, split out of connect() so the tests can drive it.
// While a history pull is running the marker waits for the pull's finally
// block: written now, it would sit above the output the pull is still
// holding (flushed after it on a skip, a downgrade or a failed fetch) or
// land in the middle of a chunked replay.
// While any load runs (a history pull or a `{t:'r'}` refresh) the marker is
// only owed, and that load's finally block settles it (_stampMarkerIfOwed()):
// written now, it would sit above the output a pull is still holding (flushed
// after it on a skip, a downgrade or a failed fetch), above a refresh's
// replay, or in the middle of a chunked replay. A pull still waiting for its
// response holds the marker too, for as long as the request takes (up to its
// budget, see _pullHistory()).
_onSocketClosed() {
this._wsReady = false;
this._wsClosed = true;
if (!this._liveQueue) this._writeDisconnectedMarker();
if (this._bufferLoading) this._markerOwed = true;
else this._writeDisconnectedMarker();
}
// Extracted so both _onSocketClosed() and a history pull that ends on a
// closed socket can write it (see _pullHistory()'s finally block).
// Settles a marker the pane owes: set when a close lands during a load (the
// replay would otherwise sit below it) or when a load wipes the terminal on
// a closed socket. Called from each load's own finally, just before
// _endBufferLoad() starts any trailing refresh.
_stampMarkerIfOwed() {
// A trailing refresh is about to clear() synchronously, while xterm parses
// a write() on a later tick: a marker written here would land in the
// freshly cleared buffer ABOVE that refresh's replay, a second, stale copy.
// The refresh re-owes the marker on a closed socket and stamps it itself.
if (this._bufferRefreshPending && !this._destroyed) return;
const owed = this._markerOwed;
this._markerOwed = false;
if (owed && this._wsClosed && !this._destroyed) this._writeDisconnectedMarker();
}
// Extracted so both _onSocketClosed() and a load that ends owing it on a
// closed socket can write it (see _stampMarkerIfOwed()).
_writeDisconnectedMarker() {
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
}
@@ -351,6 +376,7 @@
} catch {
/* Best-effort — live output still arrives once the socket connects. */
} finally {
this._stampMarkerIfOwed();
this._endBufferLoad();
}
}
@@ -430,27 +456,44 @@
// main thread) and replays it under the reader's current place. Holds the
// single-flight flag across the fetch AND the replay, like _loadBuffer().
async _pullHistory() {
// A close before the pull already wrote its marker; one during it did not.
const closedBefore = this._wsClosed;
this._bufferLoading = true;
this._liveQueue = [];
let replayed = false;
let capturedAt = 0;
// Two budgets on one signal. The request itself gets the primary pane's
// (CodemanFetchDeadline, constants.js): live output is not held while it
// runs, but the single-flight flag is, so a coalesced `{t:'r'}` refresh and
// the marker owed by a close (_onSocketClosed()) both wait for it, at worst
// for that whole budget. Once the headers land live output IS held, so the
// body read gets the short one instead: a body that hangs would otherwise
// freeze the pane for the long budget. Aborting lands in the catch below,
// which releases the flag and the queue. AbortSignal.timeout() alone cannot
// be re-armed, hence the controller; without AbortController the pull
// simply has no deadline.
const controller = global.AbortController ? new global.AbortController() : null;
let abortTimer = null;
const armDeadline = (ms) => {
if (!controller) return;
clearTimeout(abortTimer);
abortTimer = setTimeout(() => controller.abort(), ms);
};
try {
// A deadline, because live output is held for as long as this runs: a
// request that hangs would otherwise freeze the whole pane. Aborting
// lands in the catch below, which releases the flag and the queue. It
// covers the body read too, not just the headers.
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS);
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
signal: global.AbortSignal?.timeout?.(HISTORY_PULL_TIMEOUT_MS),
signal: controller?.signal,
});
armDeadline(HISTORY_PULL_TIMEOUT_MS);
// The cutoff below is the response's arrival, the same `since` rule the
// primary pane uses (_finishBufferLoad). It is a client clock standing in
// for the instant tmux took the capture, which lies somewhere in the
// round trip, so a frame in that window can be lost or doubled. Bounded
// by one round trip and not closable without a server-side capture time.
capturedAt = performance.now();
// Opened only now: a frame from before the response is either replaced by
// the capture or written unchanged, so holding it for the round trip
// bought nothing and froze the pane for as long as the fetch took.
this._liveQueue = [];
const payload = (await res.json())?.data;
clearTimeout(abortTimer);
const buffer = payload?.terminalBuffer;
const term = this.terminal;
if (!buffer || !term || this._destroyed) return;
@@ -476,6 +519,7 @@
this._historyPullUseless = false;
term.write('\x1bc');
replayed = true;
if (this._wsClosed) this._markerOwed = true;
await writeChunked(term, buffer, () => this._destroyed);
if (this._destroyed || !this.terminal) return;
// xterm parses asynchronously: an empty write's callback fires only
@@ -490,6 +534,7 @@
} catch {
/* Best-effort — live output keeps arriving whatever happens here. */
} finally {
clearTimeout(abortTimer);
const queued = this._liveQueue ?? [];
this._liveQueue = null;
// After a replay, only frames that arrived after the capture are news;
@@ -500,15 +545,14 @@
if (entry.clear) this.terminal?.clear();
else this.terminal?.write(entry.data);
}
// A replay's own `\x1bc` wipes a marker written before the pull,
// painting a fresh, current-looking history while onData keeps
// silently dropping every keystroke on the dead socket, so re-stamp it
// after a replay. A close DURING the pull wrote no marker at all
// (_onSocketClosed() defers it while the queue is live), so write it
// whether or not this pull replayed. Checked after the queue flush so
// it is the last thing on screen, matching what the close would have
// left had the pull never run.
if (this._wsClosed && (replayed || !closedBefore)) this._writeDisconnectedMarker();
// Settled after the queue flush so the marker is the last thing on
// screen: a close during the pull wrote nothing (_onSocketClosed() defers
// it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
// one written before it, which would paint a fresh, current-looking
// history while onData keeps silently dropping every keystroke on the
// dead socket. With a trailing refresh pending (_endBufferLoad) the marker
// is left to that refresh, which writes it below its own replay.
this._stampMarkerIfOwed();
this._endBufferLoad();
}
}
@@ -525,6 +569,10 @@
return;
}
this.terminal?.clear();
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue a
// trailing refresh behind a pull that the socket's close then interrupts),
// so a refresh on a closed socket owes it back once its replay is written.
if (this._wsClosed) this._markerOwed = true;
void this._loadBuffer();
}
+274 -8
View File
@@ -55,6 +55,9 @@
// (_installMobileKeyboardDismiss). Two groups: anything that is about to take
// focus itself, and the accessory bar, which is built to be used while the
// keyboard is open.
// ⚠️ A roving-tabindex widget parks every item but one at tabindex=-1, so the
// `[tabindex]` arm cannot see its items: the grouped tab rail's rows and
// headers are listed by role instead, or tapping one would drop the keyboard.
const MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR = [
'input',
'textarea',
@@ -64,6 +67,7 @@
'[contenteditable=""]',
'[contenteditable="true"]',
'[tabindex]:not([tabindex="-1"])',
'[role="treeitem"]',
'.keyboard-accessory-bar',
'.path-picker-overlay',
].join(',');
@@ -250,6 +254,235 @@ Object.assign(CodemanApp.prototype, {
this._keyCode229Recovery = null;
},
_destroyMobileImePreview() {
try {
this._mobileImePreview?.destroy?.();
} catch {
// The preview is visual-only; terminal replacement must continue.
}
this._mobileImePreview = null;
this._mobileImePreviewSessionId = null;
this._mobileImeCommitOutputSeq = null;
try {
this._mobileImePreviewNode?.remove?.();
} catch {
// Best-effort node cleanup only.
}
try {
this._mobileImePreviewHelpers?.classList?.remove('codeman-ime-preview-owned');
} catch {
// Best-effort ownership cleanup only.
}
this._mobileImePreviewNode = null;
this._mobileImePreviewHelpers = null;
try {
if (this._mobileImePreviewOfflineHandler) {
window.removeEventListener('offline', this._mobileImePreviewOfflineHandler);
}
if (this._mobileImePreviewPagehideHandler) {
window.removeEventListener('pagehide', this._mobileImePreviewPagehideHandler);
}
} catch {
// Best-effort listener cleanup only.
}
this._mobileImePreviewOfflineHandler = null;
this._mobileImePreviewPagehideHandler = null;
},
/**
* iOS Safari IME preview (mobile-ime-preview.js). WebKit does not show the
* text an IME is composing inside the terminal, so the user types blind.
*
* Two homes, chosen per render:
* - Local echo on: typed text sits in the LocalEchoOverlay and the PTY
* cursor stays at the prompt start, under the overlay's opaque text (z 7,
* `.xterm-screen`). So the overlay draws the composition itself, as an
* underlined tail after its pending text (`setComposition`).
* - Otherwise (a shell, or the overlay could not place it): a span inside
* `.xterm-helpers`, positioned by the same --xterm-helper-left/top vars as
* the helper textarea, which follow the PTY cursor.
*
* Visual only: nothing here touches the input path, and every failure
* leaves no DOM behind.
*/
_initMobileImePreview() {
this._destroyMobileImePreview();
let preview = null;
let helpers = null;
try {
if (typeof MobileImePreview === 'undefined' || !MobileImePreview?.isIosWebKitTouch?.()) return;
const textarea = this.terminal?.textarea;
helpers = this.terminal?.element?.querySelector?.('.xterm-helpers');
if (!textarea || !helpers) return;
preview = document.createElement('span');
this._mobileImePreviewNode = preview;
this._mobileImePreviewHelpers = helpers;
preview.className = 'codeman-ime-preview';
preview.setAttribute('aria-hidden', 'true');
preview.hidden = true;
helpers.appendChild(preview);
const syncPreviewTypography = () => {
try {
const compositionView =
helpers.querySelector?.('.composition-view') || this.terminal?.element?.querySelector?.('.composition-view');
if (!compositionView || !preview.style) return;
const style = typeof getComputedStyle === 'function' ? getComputedStyle(compositionView) : compositionView.style;
for (const property of ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'lineHeight', 'height']) {
const value = style?.[property] || compositionView.style?.[property];
if (value) preview.style[property] = value;
}
const theme = this.terminal?.options?.theme;
let foreground = theme?.foreground;
let background = theme?.background;
if (!foreground || !background) {
try {
const current = window.codemanCurrentXtermTheme?.();
foreground = foreground || current?.foreground;
background = background || current?.background;
} catch {
// Theme lookup is best-effort; retain the safe terminal fallback.
}
}
preview.style.color = foreground || '#e0e0e0';
// Opaque, like xterm's own composition view, so the preview does not
// overprint whatever sits at the cursor (a dim composer placeholder).
preview.style.backgroundColor = background || '#0d0d0d';
} catch {
// Typography matching is visual-only and must not block input.
}
};
// The overlay only when it is what shows typed text right now (local echo
// on, and not handed back to plain PTY echo by a composer nav key).
const localEchoOverlay = () =>
this._localEchoEnabled && !this._echoPassthroughSessions?.has(this.activeSessionId)
? this._localEchoOverlay || null
: null;
const clearOverlayComposition = () => {
try {
if (this._localEchoOverlay?.composition) this._localEchoOverlay.setComposition('');
} catch {}
};
const hideSpan = () => {
try {
preview.hidden = true;
} catch {}
try {
preview.textContent = '';
} catch {}
try {
delete preview.dataset.phase;
} catch {}
try {
helpers.classList.remove('codeman-ime-preview-owned');
} catch {}
};
const clearPreview = () => {
clearOverlayComposition();
hideSpan();
};
const controller = MobileImePreview.create({
textarea,
// An ancestor of the textarea: its capture-phase keydown listener runs
// before xterm's capture listener on the textarea, which finalizes the
// composition and emits the commit synchronously.
keydownTarget: this.terminal.element,
render: ({ text, phase }) => {
try {
const overlay = localEchoOverlay();
if (overlay && typeof overlay.setComposition === 'function') {
overlay.setComposition(text);
// No prompt found = nothing drawn: fall back to the span.
if (!text || overlay.state?.visible) {
hideSpan();
helpers.classList.toggle('codeman-ime-preview-owned', !!text);
return;
}
overlay.setComposition('');
} else {
clearOverlayComposition();
}
syncPreviewTypography();
preview.textContent = text;
preview.dataset.phase = phase;
preview.hidden = !text;
helpers.classList.toggle('codeman-ime-preview-owned', !!text);
} catch {
clearPreview();
}
},
clear: clearPreview,
});
this._mobileImePreview = controller;
this._mobileImePreviewSessionId = this.activeSessionId;
// Offline and pagehide only reset: initTerminal() runs once per page
// load, so destroying on pagehide would leave the preview off for good
// after a back-forward cache restore (iOS Safari keeps pages there).
this._mobileImePreviewOfflineHandler = () => {
try {
this._mobileImePreview?.reset?.();
} catch {
// Disconnect cleanup is visual-only.
}
};
this._mobileImePreviewPagehideHandler = this._mobileImePreviewOfflineHandler;
window.addEventListener('offline', this._mobileImePreviewOfflineHandler);
window.addEventListener('pagehide', this._mobileImePreviewPagehideHandler);
} catch {
this._destroyMobileImePreview();
}
},
/**
* Tell the IME preview about a chunk xterm emitted through onData. Returns
* true when the chunk is the IME's committed text, in which case the preview
* holds it (phase 'committed') until something else shows it. Never throws.
*/
_consumeMobileImeTerminalData(data) {
let isImeCommit = false;
try {
isImeCommit = this._mobileImePreview?.consumeTerminalData?.(data) === true;
} catch {
// The preview is visual-only; normal terminal input must continue.
}
// Output accepted from here on can carry the echo of this commit.
if (isImeCommit) this._mobileImeCommitOutputSeq = this._terminalOutputSeq || 0;
return isImeCommit;
},
/**
* Clear a committed IME preview once terminal output accepted AFTER the
* commit has been parsed. `flushedOutputSeq` is the output sequence a fully
* written flush covered (null when part of it was deferred), so output that
* was already queued before the commit can never clear it early.
*/
_noteMobileImeAuthoritativeOutput(flushedOutputSeq, sessionId) {
try {
const commitSeq = this._mobileImeCommitOutputSeq;
if (commitSeq === null || commitSeq === undefined || flushedOutputSeq === null) return;
if (sessionId !== this.activeSessionId || !(flushedOutputSeq > commitSeq)) return;
this._mobileImeCommitOutputSeq = null;
this._mobileImePreview?.noteAuthoritativeOutput?.();
} catch {
// Authoritative output is never delayed or consumed by the preview.
}
},
/**
* The local echo overlay has just taken a committed IME chunk through the
* ordinary printable/paste branch, so it now shows the text: release the
* preview instead of waiting for terminal output.
*/
_transferMobileImeCommitToLocalEcho() {
this._mobileImeCommitOutputSeq = null;
try {
this._mobileImePreview?.completeCommit?.({ predicted: true });
} catch {
// Ownership transfer is visual-only.
}
},
initTerminal() {
// Load scrollback setting from localStorage, treating DEFAULT_SCROLLBACK as a floor
// so users who picked up the previous (smaller) default get the new minimum on upgrade.
@@ -307,6 +540,7 @@ Object.assign(CodemanApp.prototype, {
const container = document.getElementById('terminalContainer');
this.terminal.open(container);
this._initMobileImePreview();
this._installMobileTapMouseGuard();
this._installShiftDragSelection();
this._installTouchSelectionFocusGuard();
@@ -447,8 +681,11 @@ Object.assign(CodemanApp.prototype, {
// xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)
// which the inner application recognizes as "insert newline" vs carriage return.
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
if (this.activeSessionId) {
// This handler also runs for keypress/keyup: xterm drops a keypress carrying Ctrl/Alt
// but NOT one carrying only Shift, so unless every event type is swallowed here,
// Shift+Enter's keypress sends a bare \r (submit) after the newline. Only keydown sends.
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
if (ev.type === 'keydown' && this.activeSessionId) {
if (this._localEchoEnabled) {
const text = this._localEchoOverlay?.pendingText || '';
this._localEchoOverlay?.clear();
@@ -1170,9 +1407,10 @@ Object.assign(CodemanApp.prototype, {
// has to re-resolve their gate before the redraw, not just move them.
this.applyLineageLineSettings?.();
this.updateConnectionLines();
if (this._localEchoOverlay?.hasPending) {
this._localEchoOverlay.rerender();
}
// Unguarded on purpose: hasPending excludes an IME composition, so a
// composition-only overlay would stay on the old prompt row. rerender()
// is a no-op when the overlay has nothing to draw.
this._localEchoOverlay?.rerender();
// Pane B (split view) has its own container and its own fit()/resize
// frame — this observer only ever measured Pane A's container, so
// without this call Pane B never learned about a window resize, an
@@ -1212,6 +1450,8 @@ Object.assign(CodemanApp.prototype, {
// survives tab switches and reconnects.
const handleTerminalData = (data) => {
// Before anything can rewrite `data`: is this chunk the IME's commit?
const isImeCommit = this._consumeMobileImeTerminalData(data);
// Mouse SGR reports (tap-to-position) are NOT IME input — they must reach
// the PTY even while the CJK input field owns focus. Without this exception
// tapping to move the cursor silently does nothing whenever Chinese input
@@ -1289,6 +1529,8 @@ Object.assign(CodemanApp.prototype, {
// When enabled, keystrokes are buffered locally in the overlay for
// instant visual feedback. Nothing is sent to the PTY until Enter
// (or a control char) is pressed — avoids out-of-order char delivery.
// An IME commit takes the same printable/paste branch as typed text,
// and the overlay then shows it in place of the preview.
if (this._localEchoEnabled && !echoPassthrough) {
if (data === '\x7f') {
const source = this._localEchoOverlay?.removeChar();
@@ -1345,6 +1587,7 @@ Object.assign(CodemanApp.prototype, {
if (data.length > 1 && data.charCodeAt(0) >= 32) {
// Paste: append to overlay only (sent on Enter)
this._localEchoOverlay?.appendText(data);
if (isImeCommit && this._localEchoOverlay) this._transferMobileImeCommitToLocalEcho();
return;
}
if (data.charCodeAt(0) < 32) {
@@ -1490,6 +1733,7 @@ Object.assign(CodemanApp.prototype, {
if (data.length === 1 && data.charCodeAt(0) >= 32) {
// Printable char: add to overlay only (sent on Enter)
this._localEchoOverlay?.addChar(data);
if (isImeCommit && this._localEchoOverlay) this._transferMobileImeCommitToLocalEcho();
return;
}
}
@@ -3155,6 +3399,7 @@ Object.assign(CodemanApp.prototype, {
const globalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
const effort = this.getEffortSetting(globalSettings);
const advisorModel = this.getAdvisorSetting(globalSettings);
// `resumeSessionId` is a Claude conversation UUID (server reads it from
// ~/.claude/projects); an external-CLI row has no such thing, so sending
// it there gets silently ignored while the OMITTED `mode` field defaults
@@ -3204,6 +3449,8 @@ Object.assign(CodemanApp.prototype, {
...modeConfig,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(effort ? { effort } : {}),
// The advisor is a claude-only feature; other CLIs would carry it inertly.
...(advisorModel && effectiveMode === 'claude' ? { advisorModel } : {}),
}),
});
const createData = await createRes.json();
@@ -3522,6 +3769,9 @@ Object.assign(CodemanApp.prototype, {
},
batchTerminalWrite(data) {
// Arrival order of output, so the IME preview can tell output that
// followed a commit from output that was already queued before it.
this._terminalOutputSeq = (this._terminalOutputSeq || 0) + 1;
// Feed the renderer watchdog. Recorded before the buffer-load early return
// below: a write that is queued rather than written still means the pipeline
// owes us a frame once it drains.
@@ -3585,6 +3835,7 @@ Object.assign(CodemanApp.prototype, {
// Accumulate raw data (may contain DEC 2026 markers)
this.pendingWrites.push(data);
this._pendingWritesOutputSeq = this._terminalOutputSeq;
this._scheduleTerminalWriteFlush();
},
@@ -3616,6 +3867,7 @@ Object.assign(CodemanApp.prototype, {
// Transfer buffered data to normal pending writes
this.pendingWrites.push(this.flickerFilterBuffer);
this._pendingWritesOutputSeq = this._terminalOutputSeq;
this.flickerFilterBuffer = '';
this.flickerFilterActive = false;
@@ -3646,6 +3898,15 @@ Object.assign(CodemanApp.prototype, {
* Position is tracked dynamically by _findPrompt() on every render.
*/
_updateLocalEchoState() {
if (this._mobileImePreviewSessionId !== this.activeSessionId) {
this._mobileImePreviewSessionId = this.activeSessionId;
this._mobileImeCommitOutputSeq = null;
try {
this._mobileImePreview?.reset?.();
} catch {
// The preview is visual-only; session switching must continue.
}
}
const settings = this.loadAppSettingsFromStorage();
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
@@ -3851,6 +4112,9 @@ Object.assign(CodemanApp.prototype, {
this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES));
deferred = true;
}
// Newest output this chunk fully contains, for the IME preview. A split
// chunk may not hold that output yet, so it reports nothing.
const flushedOutputSeq = deferred ? null : (this._pendingWritesOutputSeq ?? null);
this._terminalWriteInFlight = true;
this._terminalWriteInFlightBytes = writeChunk.length;
try {
@@ -3868,6 +4132,7 @@ Object.assign(CodemanApp.prototype, {
// because the test's write mock moved the viewport synchronously.)
this._restoreTerminalViewport(preserveViewportY, flushSessionId);
this._scheduleTerminalWriteFlush();
this._noteMobileImeAuthoritativeOutput(flushedOutputSeq, flushSessionId);
});
} catch (err) {
this._terminalWriteInFlight = false;
@@ -3897,9 +4162,10 @@ Object.assign(CodemanApp.prototype, {
// Re-position local echo overlay after terminal writes — Ink redraws can
// move the ❯ prompt to a different row, making the overlay invisible.
if (this._localEchoOverlay?.hasPending) {
this._localEchoOverlay.rerender();
}
// Unguarded on purpose: hasPending excludes an IME composition, so a
// composition-only overlay (the first word of a prompt) would otherwise
// stay on the old row. rerender() is a no-op when there is nothing to draw.
this._localEchoOverlay?.rerender();
// After Tab completion: detect the completed text in the overlay.
// Use terminal.write('', callback) to defer detection until xterm.js
+21 -10
View File
@@ -304,13 +304,26 @@ Object.assign(CodemanApp.prototype, {
let idx = startIndex;
for (const id of this.webviewOrder) {
const webview = this.webviews.get(id);
if (!webview) continue;
const isActive = id === this.activeWebviewId;
const jsonId = escapeHtml(JSON.stringify(id));
const icon = webview.icon ? escapeHtml(webview.icon) : '';
if (!this.webviews.get(id)) continue;
parts.push(this.renderWebviewTab(id, idx));
idx++;
}
return parts.join('');
},
parts.push(`<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
/**
* One web tab's HTML; `idx` is its zero-based Alt+N slot (no badge from 9 up).
* The grouped vertical rail places single web tabs into their group with this,
* so a web tab's markup is the same in every layout.
*/
renderWebviewTab(id, idx) {
const webview = this.webviews.get(id);
if (!webview) return '';
const isActive = id === this.activeWebviewId;
const jsonId = escapeHtml(JSON.stringify(id));
const icon = webview.icon ? escapeHtml(webview.icon) : '';
return `<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
onclick="app.handleWebviewTabClick(event, ${jsonId})"
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
@@ -322,10 +335,7 @@ Object.assign(CodemanApp.prototype, {
</span>
</span>
<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})" title="URL settings" aria-label="URL settings" tabindex="0">&#x2699;</span><span class="tab-close" onclick="event.stopPropagation(); app.closeWebviewTab(${jsonId})" title="Close tab" aria-label="Close web tab" tabindex="0">&times;</span></span>
</div>`);
idx++;
}
return parts.join('');
</div>`;
},
_webviewGlobeIcon() {
@@ -348,6 +358,7 @@ Object.assign(CodemanApp.prototype, {
// A web tab is active, so no session tab may also look active.
for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
}
this._syncTabTreeSelection?.(container);
},
// ── Opening / closing ─────────────────────────────────────────────────────
+2 -1
View File
@@ -18,7 +18,8 @@
* session record involved. A dropped plan therefore returns the user to
* resuming by hand, one at a time, which is where they are without this
* feature. What the plan held that a transcript does not is the owner, the
* name, the env overrides, the effort and the lineage.
* name, the env overrides, the effort, the model, the advisor model and the
* lineage.
* - Module-level singleton in the style of `web/approval-inbox.ts`: no `Session`
* import and no IO, which keeps it unit-testable and cycle-free.
* - Spending is take-then-build: `take()` removes entries synchronously, before
+2
View File
@@ -28,6 +28,8 @@ export { registerWsRoutes } from './ws-routes.js';
export { registerVoiceRoutes } from './voice-routes.js';
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
export { registerMcpSyncRoutes } from './mcp-sync-routes.js';
export { registerWebhookRoutes } from './webhook-routes.js';
export {
registerCustomModelRoutes,
refreshAllCustomModelHosts,
+97
View File
@@ -0,0 +1,97 @@
/**
* @fileoverview MCP server sync (src/mcp-sync.ts).
*
* GET /api/mcp-sync — dry run: per participating CLI, which servers it has and which it would gain.
* POST /api/mcp-sync — apply: add the missing servers to each CLI's own config file.
*
* Opt-in: both verbs answer 403 until `mcpSyncEnabled` is on (default OFF), because this writes
* OTHER tools' own user config. Writes files in the SERVER user's home, so in multi-user mode it
* is admin only. A second apply while one is running answers 409. Responses carry server names
* only, never env values, headers or file content (a parse failure is reported by position).
*
* A CLI takes part when it is ENABLED in the registry, declares an `mcpConfig`, and is installed
* or already has its config file; one that is enabled but absent from the machine is reported
* `absent` and never created. Its file is located with this process's env (the env the CLIs
* Codeman spawns inherit), so a relocation var such as `CODEX_HOME` is followed.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
ApiErrorCode,
createErrorResponse,
getErrorMessage,
type ApiResponse,
type McpSyncResult,
} from '../../types.js';
import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { enabledClis } from '../../config/cli-registry/registry.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../../utils/cli-installed-probes.js';
import { McpSyncBusyError, syncMcpServers, type McpSyncTarget } from '../../mcp-sync.js';
/** Default OFF, same shape as `readCliManagementEnabled`: read fresh so a toggle applies at once. */
export async function readMcpSyncEnabled(): Promise<boolean> {
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
return settings.mcpSyncEnabled === true;
}
/** Enabled CLIs that declare an MCP config file, in registry order (first definition wins). */
export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTarget[] {
return enabledClis()
.filter((e) => e.capabilities.mcpConfig)
.sort((a, b) => a.order - b.order)
.map((e) => ({
id: e.id,
label: e.label,
...e.capabilities.mcpConfig!,
installed: isCliEntryInstalled(e, availability),
}));
}
/**
* Installed, enabled agent CLIs with no known MCP config file (sync cannot touch them). One that
* is not installed is left out, the same way a supported one that is not installed reads `absent`.
*/
export function mcpUnsupportedLabels(availability: Record<string, boolean>): string[] {
return enabledClis()
.filter((e) => e.kind === 'agent' && !e.capabilities.mcpConfig && isCliEntryInstalled(e, availability))
.map((e) => e.label);
}
async function gate(req: FastifyRequest): Promise<ApiResponse<never> | null> {
if (isMultiUserMode() && !isAdmin(req)) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
if (!(await readMcpSyncEnabled())) {
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
'MCP sync is disabled. Turn on "Enable MCP server sync" in Settings and save first.'
);
}
return null;
}
export function registerMcpSyncRoutes(app: FastifyInstance): void {
const run = async (req: FastifyRequest, reply: FastifyReply, apply: boolean): Promise<ApiResponse<McpSyncResult>> => {
const denied = await gate(req);
if (denied) {
reply.code(403);
return denied;
}
try {
const availability = await probeStockCliAvailability();
const targets = mcpSyncTargets(availability);
const data = await syncMcpServers(targets, { apply, env: process.env }, mcpUnsupportedLabels(availability));
return { success: true, data };
} catch (err) {
if (err instanceof McpSyncBusyError) {
reply.code(409);
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
}
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
};
app.get('/api/mcp-sync', (req, reply) => run(req, reply, false));
app.post('/api/mcp-sync', (req, reply) => run(req, reply, true));
}
+2
View File
@@ -293,6 +293,7 @@ export function registerRalphRoutes(
planItems,
envOverrides,
effort,
advisorModel,
} = parseBody(RalphLoopStartSchema, req.body);
// Multi-user: cases live in the requesting user's space.
@@ -343,6 +344,7 @@ export function registerRalphRoutes(
allowedTools: rlClaudeModeConfig.allowedTools,
envOverrides,
effort,
advisorModel,
owner: rlOwner,
});
+2
View File
@@ -195,6 +195,8 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
(saved as { __envOverrides?: Record<string, string> }).__envOverrides
),
effort: saved.effort,
model: saved.model,
advisorModel: saved.advisorModel,
attachmentHistory:
(saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ??
saved.attachmentHistory,
+55 -16
View File
@@ -30,7 +30,13 @@ import {
type OmpConfig,
type RemoteHost,
} from '../../types.js';
import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
import {
Session,
cliTakesSessionModel,
isAltScreenStripMode,
isExternalCliMode,
isMuxAltScreenOnlyStripMode,
} from '../../session.js';
import type { PaneCaptureOptions } from '../../mux-interface.js';
import { SseEvent } from '../sse-events.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
@@ -107,6 +113,7 @@ import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-mar
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
import type { NewlineSequence } from '../../config/cli-registry/types.js';
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
import { isMultiUserMode } from '../../config/multiuser.js';
@@ -888,6 +895,25 @@ export function registerSessionRoutes(
if (capMsg) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg);
const body = parseBody(CreateSessionSchema, req.body);
// The top-level `model` is Claude's per-session `--model`. Every other CLI takes its model
// in its own config object (`codexConfig.model` and so on), so a `model` here would be
// dropped without a word; refuse it before anything is written for the session.
if (body.model && !cliTakesSessionModel(body.mode ?? 'claude')) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'model applies to claude sessions only; other CLIs take their model in their own config object, such as codexConfig.model'
);
}
// An attach launches nothing (the remote agent is already running), so a launch model
// or advisor would be dropped the same way, so both are refused, as they have been since
// they were added. The older launch fields (effort, envOverrides) predate this and keep
// their silent ignore here, since refusing them now would break existing callers.
if (body.attachRemoteSession && (body.model || body.advisorModel)) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'model and advisorModel shape a new launch, and attachRemoteSession launches nothing; leave them out when attaching'
);
}
let workingDir = body.workingDir || process.cwd();
let remote = undefined;
@@ -1073,9 +1099,10 @@ export function registerSessionRoutes(
// genuinely different mechanisms:
// 'flag' — the CLI takes --model, so read the value the caller sent
// in that CLI's own config object.
// 'claude-settings-file' — claude alone, whose model is written to
// <case>/.claude/settings.local.json rather than passed as
// a flag, so the app-wide default applies here.
// 'claude-settings-file' — claude alone, whose persistent model is written to
// <case>/.claude/settings.local.json (`modelOverride`). A
// per-session `model` from the caller goes out as --model and
// wins; without one, the app-wide default applies.
// 'none' — shell has no model; deepseek's is a composition entry in
// the profile's config tree, not a session field
// (docs/deepseek-integration.md). Both get nothing.
@@ -1086,7 +1113,7 @@ export function registerSessionRoutes(
| string
| undefined)
: modelSource?.source === 'claude-settings-file'
? modelConfig?.defaultModel || undefined
? body.model || modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
// Section 6.3: force non-granted users to a classifier-guarded mode.
@@ -1130,6 +1157,7 @@ export function registerSessionRoutes(
resumeSessionId: validatedResumeId,
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
effort: body.effort,
advisorModel: body.advisorModel,
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
remote,
owner,
@@ -2092,21 +2120,16 @@ export function registerSessionRoutes(
// ========== Send Named Key (tmux send-keys -H) ==========
// Sends raw hex bytes to tmux pane for keys like Shift+Enter / Ctrl+Enter.
// Uses send-keys -H (hex) to inject 0x0a (line feed) which Claude Code's
// Ink input recognizes as "insert newline" vs 0x0d (carriage return = submit).
// Uses send-keys -H (hex) to inject a newline chord: 0x0a (line feed) by default, or the CLI's
// own `capabilities.newline`. Claude Code's Ink input recognizes 0x0a as "insert newline" vs
// 0x0d (carriage return = submit).
app.post('/api/sessions/:id/send-key', async (req) => {
const { id } = req.params as { id: string };
const body = req.body as Record<string, unknown>;
const key = typeof body?.key === 'string' ? body.key : '';
// Map key names to hex byte sequences
const KEY_HEX_MAP: Record<string, string[]> = {
'S-Enter': ['0a'], // \n (line feed)
'C-Enter': ['0a'], // \n (line feed)
};
const hex = KEY_HEX_MAP[key];
if (!hex) {
if (key !== 'S-Enter' && key !== 'C-Enter') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`);
}
@@ -2116,6 +2139,18 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
}
// Key names map to hex byte sequences. Ctrl+Enter is always a line feed; Shift+Enter is the
// CLI's own newline chord (`capabilities.newline`, default line feed), so a CLI that wants
// Esc+Enter declares it in the registry instead of being special-cased here.
const NEWLINE_HEX: Record<NewlineSequence, string[]> = {
'line-feed': ['0a'], // \n
'esc-enter': ['1b', '0d'], // ESC CR, the Alt/Option+Enter chord
};
const hex =
key === 'C-Enter'
? NEWLINE_HEX['line-feed']
: NEWLINE_HEX[getCli(session.mode)?.capabilities.newline ?? 'line-feed'];
try {
// Route through the dedicated Codeman socket — bare `tmux` would target the
// user's default server and never find this session (same #80 regression class).
@@ -3393,6 +3428,7 @@ export function registerSessionRoutes(
ompConfig,
envOverrides,
effort,
advisorModel,
parentSessionId,
agentOrigin,
customModel,
@@ -3440,6 +3476,7 @@ export function registerSessionRoutes(
if (
(envOverrides && Object.keys(envOverrides).length > 0) ||
effort ||
advisorModel ||
modelOverride !== undefined ||
codexConfig ||
geminiConfig ||
@@ -3453,7 +3490,7 @@ export function registerSessionRoutes(
) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'envOverrides, effort, modelOverride, per-CLI config, and custom model endpoints are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
'envOverrides, effort, advisorModel, modelOverride, per-CLI config, and custom model endpoints are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
);
}
@@ -3510,6 +3547,7 @@ export function registerSessionRoutes(
if (
(envOverrides && Object.keys(envOverrides).length > 0) ||
effort ||
advisorModel ||
codexConfig ||
geminiConfig ||
antigravityConfig ||
@@ -3522,7 +3560,7 @@ export function registerSessionRoutes(
) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'envOverrides, effort, per-CLI config, and custom model endpoints are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.'
'envOverrides, effort, advisorModel, per-CLI config, and custom model endpoints are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.'
);
}
@@ -3977,6 +4015,7 @@ export function registerSessionRoutes(
ompConfig: qsResolvedOmpConfig,
envOverrides: qsCustomModelEnvOverrides,
effort,
advisorModel,
remote,
docker,
resumeSessionId: dockerResumeId,
+109
View File
@@ -0,0 +1,109 @@
/**
* @fileoverview Webhook notification settings (src/webhook-notify.ts).
*
* GET /api/webhook — the config WITHOUT its URL (scheme + host only), and the last delivery result
* PUT /api/webhook — change enabled / kind / url / scope; an empty `url` clears it
* POST /api/webhook/test — send one test message with the saved config
*
* The URL is a bearer secret (anyone holding a Slack/Discord webhook URL can post as it), so it is
* stored in its own 0600 file and never returned. In multi-user mode all three routes are admin only:
* the channel receives every session's events, the same reach an admin's own Web Push has.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
ApiErrorCode,
createErrorResponse,
getErrorMessage,
type ApiResponse,
type WebhookResult,
type WebhookStatus,
} from '../../types.js';
import { isAdmin, parseBody } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { WebhookUpdateSchema } from '../schemas.js';
import {
maskWebhookUrl,
readWebhookConfig,
webhookUrlProblem,
writeWebhookConfig,
type WebhookNotifier,
} from '../../webhook-notify.js';
export interface WebhookRouteDeps {
notifier: WebhookNotifier;
configDir: string;
/** The instance's window title, so a test message says which machine sent it. */
hostTitle: () => string;
}
export function registerWebhookRoutes(app: FastifyInstance, deps: WebhookRouteDeps): void {
const denied = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
return null;
};
const status = async (): Promise<WebhookStatus> => {
const cfg = await readWebhookConfig(deps.configDir);
return {
enabled: cfg.enabled,
kind: cfg.kind,
scope: cfg.scope,
hasUrl: cfg.url !== '',
urlMasked: maskWebhookUrl(cfg.url),
lastResult: deps.notifier.lastResult,
};
};
app.get('/api/webhook', async (req, reply): Promise<ApiResponse<WebhookStatus>> => {
const no = denied(req, reply);
if (no) return no;
return { success: true, data: await status() };
});
app.put('/api/webhook', async (req, reply): Promise<ApiResponse<WebhookStatus>> => {
const no = denied(req, reply);
if (no) return no;
const patch = parseBody(WebhookUpdateSchema, req.body, 'Invalid webhook settings');
const current = await readWebhookConfig(deps.configDir);
const next = {
enabled: patch.enabled ?? current.enabled,
kind: patch.kind ?? current.kind,
scope: patch.scope ?? current.scope,
url: patch.url !== undefined ? patch.url.trim() : current.url,
};
if (next.url) {
const problem = webhookUrlProblem(next.url);
if (problem) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
}
}
if (next.enabled && !next.url) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Add a webhook URL before enabling notifications');
}
try {
await writeWebhookConfig(deps.configDir, next);
} catch (err) {
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
return { success: true, data: await status() };
});
app.post('/api/webhook/test', async (req, reply): Promise<ApiResponse<WebhookResult>> => {
const no = denied(req, reply);
if (no) return no;
const cfg = await readWebhookConfig(deps.configDir);
if (!cfg.url) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Save a webhook URL first');
}
// 200 even when delivery failed: the request to Codeman worked, `data.ok` says whether the webhook did.
return { success: true, data: await deps.notifier.sendTest(cfg, deps.hostTitle()) };
});
}
+66
View File
@@ -18,11 +18,14 @@ import {
MIN_TERMINAL_SCROLLBACK_LINES,
} from '../config/terminal-history.js';
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
import { CODEX_REASONING_EFFORTS } from '../types/session.js';
import { WEBHOOK_KINDS, WEBHOOK_SCOPES } from '../types/push.js';
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js';
import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js';
import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js';
import type { SessionMode } from '../types.js';
import { isAdvisorModel } from '../types/session.js';
// ========== Path Validation ==========
@@ -251,6 +254,20 @@ const safeEnvOverridesSchema = z
*/
const effortLevelSchema = z.enum(['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']).optional();
/**
* Claude advisor model for new sessions: `fable`/`opus`/`sonnet` or a full model id in one of
* those families (isAdvisorModel). Merged into the launch `--settings` JSON as `advisorModel`,
* a soft default that /advisor still switches in-session. The allowlist is also the injection
* guard for the single-quoted `--settings` argument.
*/
const advisorModelSchema = z
.string()
.max(64)
.refine((value) => isAdvisorModel(value), {
message: 'advisorModel must be fable, opus, sonnet or a full claude-fable/opus/sonnet model id',
})
.optional();
// ========== Session Routes ==========
/**
@@ -298,6 +315,7 @@ const CodexConfigSchema = z
.max(100)
.regex(/^[a-zA-Z0-9._\-/]+$/)
.optional(),
reasoningEffort: z.enum(CODEX_REASONING_EFFORTS).optional(),
resumeSessionId: z
.string()
.max(100)
@@ -524,8 +542,25 @@ export const CreateSessionSchema = z.object({
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel: advisorModelSchema,
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
modelOverride: z.string().max(50).optional(),
/**
* Claude model for THIS session only, passed as `claude --model <id>`; nothing is written to
* disk. Wins over the app-wide default model. A subset of the registry's `model-claude`
* pattern, so a value accepted here is never rejected at launch. The first character must be
* a letter or digit: the value lands in argv, and no model id opens with `-`, so a
* flag-shaped value is refused here rather than left to the launch quoting. An empty string
* means no per-session model, as it does for `modelOverride`. Claude only: the route refuses
* it for any other CLI and on a remote attach.
*/
model: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-[\]]*$/)
.or(z.literal(''))
.optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema,
@@ -1055,6 +1090,8 @@ export const QuickStartSchema = z.object({
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel: advisorModelSchema,
/**
* Who is spawning this worker (`codeman-skill` from the packaged agent skill), or,
* equivalently, the `X-Codeman-Agent-Origin` header; the body wins when both are
@@ -1327,6 +1364,13 @@ export const SettingsUpdateSchema = z
* endpoints (PUT/POST/DELETE /api/clis...) answer instead of refusing outright.
*/
cliManagementEnabled: z.boolean().optional(),
/**
* MCP server sync (src/mcp-sync.ts): copies each enabled CLI's user-level MCP servers into
* the other CLIs' own config files. SYNCED, default OFF: it writes other tools' config in
* the server user's home (including any env values and headers on the servers), so it is
* opt-in. While OFF, GET/POST /api/mcp-sync answer 403 and the Settings controls are hidden.
*/
mcpSyncEnabled: z.boolean().optional(),
/**
* Read My Mind predictor model override. Empty/absent = the AI-checker
* default (opus: prediction quality is the product and it runs only on an
@@ -1374,6 +1418,12 @@ export const SettingsUpdateSchema = z
// auto-reattached.
remoteAutoReconnect: z.boolean().optional(),
thinkingEffort: z.string().max(20).optional(),
/** Advisor model for new Claude sessions ('' = leave it to the CLI's own /advisor choice). */
claudeAdvisorModel: z
.string()
.max(64)
.refine((value) => value === '' || isAdvisorModel(value), { message: 'Invalid advisor model' })
.optional(),
// UI visibility
showFontControls: z.boolean().optional(),
showSystemStats: z.boolean().optional(),
@@ -1846,6 +1896,20 @@ export const PushPreferencesUpdateSchema = z.object({
pushPreferences: z.record(z.string(), z.boolean()),
});
/**
* PUT /api/webhook. `.strict()` like every settings-shaped schema; `url` is optional so a change of
* kind or scope never needs the secret re-sent, and an empty string clears it. The kind and scope
* lists are the store's own, so the schema can never accept a value the store would coerce away.
*/
export const WebhookUpdateSchema = z
.object({
enabled: z.boolean().optional(),
kind: z.enum(WEBHOOK_KINDS).optional(),
scope: z.enum(WEBHOOK_SCOPES).optional(),
url: z.string().max(2048).optional(),
})
.strict();
// ========== Ralph Loop ==========
/** POST /api/ralph-loop/start */
@@ -1862,6 +1926,8 @@ export const RalphLoopStartSchema = z.object({
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel: advisorModelSchema,
planItems: z
.array(
z.object({
+56 -22
View File
@@ -44,6 +44,9 @@ import { hostname as getHostname, uptime as osUptime } from 'node:os';
import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from '../reboot-restore.js';
import { rebootRestoreRegistry } from './reboot-restore-registry.js';
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
import { WebhookNotifier, readWebhookConfig } from '../webhook-notify.js';
import type { WebhookUrgency } from '../types/push.js';
import { webviewFetch } from './webview-egress.js';
import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.js';
import type { RemoteWakeRegistry } from '../remote-wake.js';
import { normalizeBasePath, stripBasePath, joinBasePath } from '../config/base-path.js';
@@ -197,6 +200,8 @@ import {
registerVoiceRoutes,
registerWebviewRoutes,
registerTabLayoutRoutes,
registerMcpSyncRoutes,
registerWebhookRoutes,
registerCustomModelRoutes,
refreshAllCustomModelHosts,
readCustomModelEndpointsEnabled,
@@ -368,6 +373,8 @@ export class WebServer extends EventEmitter {
private hookSecretFailures: StaleExpirationMap<string, number> | null = null;
private userFailures: StaleExpirationMap<string, number> | null = null;
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
/** ntfy / Slack / Discord / generic webhook for the push events; config in webhook.json (0600). */
private webhookNotifier = new WebhookNotifier(() => readWebhookConfig(getDataDir()), webviewFetch);
private teamWatcher: TeamWatcher = new TeamWatcher();
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
private readonly titleHostname: string;
@@ -1130,6 +1137,12 @@ export class WebServer extends EventEmitter {
registerOrchestratorRoutes(this.app, ctx);
registerWebviewRoutes(this.app, ctx, this.basePath);
registerTabLayoutRoutes(this.app, ctx);
registerMcpSyncRoutes(this.app);
registerWebhookRoutes(this.app, {
notifier: this.webhookNotifier,
configDir: getDataDir(),
hostTitle: () => this.windowTitle,
});
registerCustomModelRoutes(this.app);
registerCliRegistryRoutes(this.app);
@@ -2652,6 +2665,25 @@ export class WebServer extends EventEmitter {
const template = WebServer.PUSH_EVENT_MAP[event];
if (!template) return;
const sessionName = (data.sessionName as string) || '';
const sessionId = (data.sessionId as string) || '';
const body = WebServer.pushBodyText(event, data, sessionName);
// Webhook channel (ntfy / Slack / Discord / generic): independent of Web Push, so it runs BEFORE
// the "no subscriptions" return below, which is exactly the headless-server case it exists for.
// Fire-and-forget; WebhookNotifier dedupes, caps what is in flight and never throws.
void this.webhookNotifier
.notify({
event,
title: template.title,
body,
urgency: template.urgency as WebhookUrgency,
sessionId: sessionId || undefined,
sessionName: sessionName || undefined,
host: this.windowTitle,
})
.catch(() => undefined);
const subscriptions = this.pushStore.getAll();
if (subscriptions.length === 0) return;
@@ -2670,9 +2702,6 @@ export class WebServer extends EventEmitter {
const vapidKeys = this.pushStore.getVapidKeys();
webpush.setVapidDetails('mailto:codeman@localhost', vapidKeys.publicKey, vapidKeys.privateKey);
const sessionName = (data.sessionName as string) || '';
const sessionId = (data.sessionId as string) || '';
// Multi-user: a session-scoped push (all PUSH_EVENT_MAP events carry a sessionId)
// must reach only the owner's devices (+ admins) — the body embeds the session
// name + activity, so cross-user delivery would leak it. Resolved once here; the
@@ -2680,25 +2709,6 @@ export class WebServer extends EventEmitter {
const multiUserPush = isMultiUserMode();
const pushSessionOwner = sessionId ? this.sessions.get(sessionId)?.owner : undefined;
// Build body text from event data
let body = sessionName ? `[${sessionName}]` : '';
if (event === SseEvent.SessionError && data.error) {
body += body ? ' ' : '';
body += String(data.error).slice(0, 200);
} else if (event === SseEvent.RespawnBlocked && data.reason) {
body += body ? ' ' : '';
body += String(data.reason);
} else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
body += body ? ' ' : '';
body += String(data.phrase);
} else if (event === SseEvent.SessionRespawnBreakerTripped && data.count) {
body += body ? ' ' : '';
body += `Stopped after ${Number(data.count)} rapid crashes — restart the session to retry`;
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
body += body ? ' ' : '';
body += `Tool: ${String(data.tool_name)}`;
}
const payload = JSON.stringify({
title: template.title,
// Hostname-aware prefix so OS-level notifications from multiple Codeman
@@ -2752,6 +2762,28 @@ export class WebServer extends EventEmitter {
}
}
/** The notification body for an event (shared by Web Push and the webhook channel). */
private static pushBodyText(event: string, data: Record<string, unknown>, sessionName: string): string {
let body = sessionName ? `[${sessionName}]` : '';
if (event === SseEvent.SessionError && data.error) {
body += body ? ' ' : '';
body += String(data.error).slice(0, 200);
} else if (event === SseEvent.RespawnBlocked && data.reason) {
body += body ? ' ' : '';
body += String(data.reason);
} else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
body += body ? ' ' : '';
body += String(data.phrase);
} else if (event === SseEvent.SessionRespawnBreakerTripped && data.count) {
body += body ? ' ' : '';
body += `Stopped after ${Number(data.count)} rapid crashes — restart the session to retry`;
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
body += body ? ' ' : '';
body += `Tool: ${String(data.tool_name)}`;
}
return body;
}
private cleanupDeadSSEClients(): void {
this.sse.cleanupDeadClients();
}
@@ -3489,6 +3521,8 @@ export class WebServer extends EventEmitter {
ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
model: savedState?.model,
advisorModel: savedState?.advisorModel,
attachmentHistory: savedAttachmentHistory,
// The pane's last Enter. Without it the response viewer would show
// the launch conversation until the user types again, even though