feat(git): a Fetch button in the Git status header, and an interval for it app-wide and per case

Fetch is the only git command Codeman runs that writes, and the only one that runs a
repository's own fetch-post hook, so it stays behind a click and off by default. The Git
status window's header row gains Fetch; App Settings -> Bottom bar gains the switch and
the interval, synced because the scheduler acts on them, and Session Options -> Git
overrides both per case. The scheduler reads settings fresh each tick, times the interval
from the start of a run, never overlaps runs, and re-checks the Docker and remote rules
every tick before any git runs. Nothing but remote-tracking refs moves: HEAD, the index
and the working tree are proven unchanged against real git while origin/main advances.

Tests: src/git-fetch.ts and the scheduler, the per-case settings route, the synced/display
setting split, the two new i18n rows, and the Fetch block of the Git status browser suite.
This commit is contained in:
Devvyn committed 2026-10-25 17:23:55 +08:00
1 parent 3e06f3c141
commit 9432d9f9e2
35 files changed
+2340 -39

No files matched your search

+3
View File
@@ -2455,6 +2455,9 @@ class CodemanApp {
// Update parentSessionName for any subagents belonging to this session
// (fixes stale name display after session rename)
this.updateSubagentParentNames(session.id);
// A scheduled fetch publishes the case's result on this same event, so an open Session Options modal on
// that case shows it without polling its own (session-ui.js: updateGitFetchStatus).
if (this.editingSessionId === session.id) this.updateGitFetchStatus(session.id);
// If claudeSessionId was just set, re-check orphan subagents
// This connects subagents that were waiting for the session to identify itself
if (claudeSessionIdJustSet) {
+83 -5
View File
@@ -9,9 +9,11 @@
* OPTIONAL and per-device: `showGitStatus` (App Settings → Header & Panels → Bottom bar), default
* OFF. While it is off nothing polls and the button never shows. While it is on, the page asks
* `GET /api/sessions/:id/git-status` for the active session on a slow poll (and at once when the
* session changes or the window regains focus). The route is read-only and offline: it never fetches
* or changes the repository, so the "behind" number reflects the last `git fetch`, which the panel
* footer says. Remote (SSH) and Docker sessions answer `unsupported` and show no indicator.
* session changes or the window regains focus). The status route is read-only: it never changes the
* repository, so the "behind" number reflects the last `git fetch`, which the panel footer says. Fetching
* happens only when the user clicks Fetch in that window's header (`POST /api/sessions/:id/git-fetch`,
* src/git-fetch.ts): the only git command Codeman runs that writes, and only on that click. Remote (SSH)
* and Docker sessions answer `unsupported`, show no indicator, and cannot be fetched.
*
* Everything that comes from git (file names, commit subjects, author names) is untrusted text: it is
* only ever written with `textContent`, never `innerHTML`.
@@ -265,6 +267,75 @@ Object.assign(CodemanApp.prototype, {
return this.refreshGitStatus({ fresh: true });
},
/**
* Fetch, on this click only: one `git fetch --quiet` per repository the panel shows
* (`POST /api/sessions/:id/git-fetch`). It is the only git command Codeman runs that writes, and it
* updates remote-tracking refs only, so an agent working in the same repository is not disturbed. One
* click means one fetch per repository: the button is busy and a second click is ignored while it runs.
* The panel then re-reads, so the "behind" number reflects what this fetch brought.
*/
async fetchGitStatusNow() {
const sid = this.activeSessionId || null;
const btn = this.$('gitStatusFetchBtn');
if (!sid || !btn || this._gitFetchInFlight) return;
this._gitFetchInFlight = true;
this._renderGitFetchButton();
let report = null;
try {
const query = new URLSearchParams(this.gitStatusLimits());
report = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-fetch?${query}`, { method: 'POST' });
} catch {
report = null;
}
// A fetch that lands after a session switch is not shown for the session on screen now; the refs it
// moved are still on disk, so that session's own read reflects them.
if (sid === this.activeSessionId) this._gitStatusFetch = report ? { sessionId: sid, data: report } : null;
this._gitFetchInFlight = false;
await this.refreshGitStatus({ fresh: true });
},
/** The last Fetch this window ran for the session on screen, or null. */
_currentGitFetch() {
const f = this._gitStatusFetch;
return f && f.sessionId === this.activeSessionId && f.data ? f.data : null;
},
/** Busy while a fetch runs, and never enabled for a session there is nothing to fetch for. */
_renderGitFetchButton() {
const btn = this.$('gitStatusFetchBtn');
if (!btn) return;
const overview = this._currentGitStatus();
const fetchable = !!this.activeSessionId && !!overview && overview.state === 'ok';
btn.disabled = this._gitFetchInFlight || !fetchable;
if (this._gitFetchInFlight) btn.setAttribute('aria-busy', 'true');
else btn.removeAttribute('aria-busy');
},
/** What the last Fetch brought: a summary line, then one row per repository it could not fetch. */
_gitFetchNote() {
const report = this._currentGitFetch();
if (!report || report.state !== 'ok' || !report.repos?.length) return null;
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
const failed = report.repos.filter((r) => !r.ok);
const count = report.repos.length;
const note = el('div', 'git-status-fetch-note');
note.append(
el(
'div',
'git-status-fetch-summary',
`Fetched ${new Date(report.fetchedAt).toLocaleTimeString()}: ${count - failed.length} of ${count} ${count === 1 ? 'repository' : 'repositories'}.`
)
);
for (const r of failed) {
const row = el('div', 'git-status-fetch-row');
row.append(el('span', 'git-status-repo-name', r.name));
if (r.path !== r.name) row.append(el('span', 'git-status-repo-path', r.path));
row.append(el('span', 'git-status-repo-unreadable', `⚠ ${r.failure?.message || 'git fetch failed'}`));
note.append(row);
}
return note;
},
/** Drag by the header. Pointer events cover mouse, pen and touch; one set of listeners lives as long as the page. */
_ensureGitStatusPanelDrag() {
const panel = this.$('gitStatusPanel');
@@ -313,6 +384,7 @@ Object.assign(CodemanApp.prototype, {
if (!body) return;
const overview = this._currentGitStatus();
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
this._renderGitFetchButton();
// The 15 s poll replaces every row: put keyboard focus back on the same file afterwards.
const focusKey = body.contains(document.activeElement)
? document.activeElement.closest?.('[data-git-key]')?.dataset.gitKey
@@ -379,8 +451,13 @@ Object.assign(CodemanApp.prototype, {
}
}
const note = this._gitFetchNote();
if (note) body.append(note);
if (foot) {
foot.textContent = `Checked ${new Date(overview.checkedAt).toLocaleTimeString()}. Read-only: Codeman never fetches or changes the repository, so “behind” is as of your last fetch.`;
// Runtime text (the panel footer carries data-i18n-skip), so naming the interval here does not fight the
// translator. It says what actually runs: this click, or the interval the user turned on.
foot.textContent = `Checked ${new Date(overview.checkedAt).toLocaleTimeString()}. Read-only apart from Fetch: Codeman fetches on your click, or on the interval you turned on in Settings, and never commits or changes the repository, so “behind” is as of the last fetch.`;
}
if (focusKey) {
const again = [...body.querySelectorAll('[data-git-key]')].find((n) => n.dataset.gitKey === focusKey);
@@ -449,7 +526,8 @@ Object.assign(CodemanApp.prototype, {
if (data.ahead) line.append(el('span', 'git-status-chip git-status-chip--warn', `↑ ${data.ahead} ahead`));
if (data.behind) {
const behind = el('span', 'git-status-chip', `↓ ${data.behind} behind`);
behind.title = 'As of the last git fetch: Codeman never fetches.';
behind.title =
'As of the last git fetch — click Fetch in this window, or turn on the fetch interval in Settings, to bring it current.';
line.append(behind);
}
} else if (data.hasRemote) {
+27 -2
View File
@@ -351,6 +351,7 @@
Delete: '删除',
Remove: '移除',
Edit: '编辑',
Fetch: '拉取',
Refresh: '刷新',
Back: '返回',
Next: '下一步',
@@ -523,8 +524,8 @@
// #543's two included). Keys are the trimmed label text, without the scope tag.
'Bottom bar': '底部栏',
'Git status': 'Git 状态',
"Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only: Codeman never fetches or changes the repository. Not shown for Docker or remote sessions. Off by default.":
'在底部栏右侧显示当前会话的仓库(或其文件夹内向下两层以内的每个仓库)是否有未提交的文件或未推送的提交。点击可查看列表。只读:{name} 从不拉取或更改仓库。Docker 和远程会话不显示。默认关闭。',
"Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` — the one git command here that writes — runs only when you click Fetch in that window, or on the interval below if you turned it on. Not shown for Docker or remote sessions. Off by default.":
'在底部栏右侧显示当前会话的仓库(或其文件夹内向下两层以内的每个仓库)是否有未提交的文件或未推送的提交。点击可查看列表。除拉取外只读:{name} 从不提交或更改仓库,而拉取(这里唯一会写入的 git 命令)只在您点击该窗口中的拉取按钮时运行,或在您开启了下方的定时后按间隔运行。Docker 和远程会话不显示。默认关闭。',
'Git status: group files by folder': 'Git 状态:按文件夹分组显示文件',
'In the Git window, show changed files under their folders, collapsed until you click a folder. Off lists every file by its full path. On by default.':
'在 Git 窗口中,将更改的文件显示在各自的文件夹下,点击文件夹前保持折叠。关闭时按完整路径列出每个文件。默认开启。',
@@ -534,8 +535,32 @@
'Git status: git timeout': 'Git 状态:git 超时',
'Seconds one git command may run before that repository is reported as unreadable (5 to 120, default 30). Raise it for repositories on a slow network share.':
'单条 git 命令可运行的秒数,超时后该仓库会被报告为无法读取(5 到 120,默认 30)。仓库位于较慢的网络共享上时请调高此值。',
'Fetch git status': '拉取 Git 状态',
'Refresh git status': '刷新 Git 状态',
'Close git status': '关闭 Git 状态',
// Git fetch on an interval (App Settings → Bottom bar, Session Options → Git, and the save toast in
// session-ui.js). 'Status' and 'Git' are not added: the first already has a key, the second stays a
// technical name, and the status row's text is written at runtime.
"Whether Codeman fetches this case's repositories on a timer.": '{name} 是否按间隔拉取此案例的仓库。',
'Git fetch on an interval': 'Git 定时拉取',
'Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the one git command Codeman runs that writes, and the one that runs a repository\'s own hooks, so nothing runs on a timer until you turn it on. A case can override both the switch and the interval in Session Options → Git.':
'按间隔为 Git 窗口列出的仓库运行拉取,这样“落后”的数字无需点击就是最新的。默认关闭:这是 {name} 运行的唯一会写入的 git 命令,也是唯一会运行仓库自身钩子的命令,所以在您开启之前不会有任何定时运行。案例可以在“会话选项 → Git”中同时覆盖开关和间隔。',
'Git fetch: how often': 'Git 定时拉取:多久一次',
'Seconds between runs for a case that has no override of its own (30 to 3600, default 300). Only a case that is on — here, or in Session Options → Git — is fetched.':
'没有自己覆盖设置的案例,两次运行之间的秒数(30 到 3600,默认 300)。只有已开启的案例——这里,或在“会话选项 → Git”——才会被拉取。',
'Fetch on an interval': '按间隔拉取',
"Fetching is the only git command Codeman runs that writes, and the only one that runs a repository's own hooks, so nothing runs on a timer until you turn it on — here, or in App Settings → Bottom bar. The panel's Fetch button is always just a click.":
'拉取是 {name} 运行的唯一会写入的 git 命令,也是唯一会运行仓库自身钩子的命令,所以在您开启之前(这里,或“应用设置 → 底部栏”)不会有任何定时运行。该窗口中的拉取按钮始终只是一次点击。',
'Follow the app setting': '跟随应用设置',
'Uses the app-wide switch and interval for this case. Switch it off to pick your own.':
'为此案例使用应用级的开关和间隔。关闭它即可自行选择。',
'Fetch this case': '拉取此案例',
'Runs on its own even while the app-wide switch is off: a case that turned itself on means exactly that.':
'即使应用级开关关闭也自行运行:一个自己开启的案例正是这个意思。',
'How often': '多久一次',
'30 sec to 1 hr, timed from the start of a run, so a slow repository does not push the next one out.':
'30 秒到 1 小时,从一次运行的开始计时,所以较慢的仓库不会把下一次推远。',
'Git fetch settings saved': 'Git 定时拉取设置已保存',
Panels: '面板',
Monitor: '监视器',
'Project Insights': '项目洞察',
+69 -1
View File
@@ -625,6 +625,7 @@
<span class="git-status-title">Git <span class="git-status-branch" id="gitStatusBranch" data-i18n-skip></span></span>
<div class="git-status-actions">
<button class="btn-icon-sm" onclick="app.refreshGitStatusNow()" title="Refresh" aria-label="Refresh git status">&#x21BB;</button>
<button class="btn-icon-sm" id="gitStatusFetchBtn" onclick="app.fetchGitStatusNow()" title="Fetch" aria-label="Fetch git status">&#x2913;</button>
<button class="btn-icon-sm" onclick="app.closeGitStatusPanel()" title="Close" aria-label="Close git status">&times;</button>
</div>
</div>
@@ -1331,6 +1332,12 @@
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3v18h18"/><polyline points="7 14 11 10 14 13 20 7"/></svg>
<span>Summary</span>
</button>
<!-- Git is last: a case that has not turned fetching on shows nothing here, so the section only
earns its place once the feature is in use. -->
<button type="button" class="set-rail-item" data-tab="git" onclick="app.switchOptionsTab('git')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M6 3v12"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
<span>Git</span>
</button>
</div>
<p class="set-rail-foot">These options belong to the selected session and save as you change them.</p>
</nav>
@@ -1719,6 +1726,53 @@
</div>
</div>
</section>
<!-- ══ Git ══════════════════════════════════════════════════════ -->
<section class="set-section hidden" id="git-tab" data-label="Git">
<div class="set-section-head">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M6 3v12"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
<h2>Git</h2>
</div>
<p class="set-section-blurb">Whether Codeman fetches this case's repositories on a timer.</p>
<div class="set-group">
<div class="set-group-head"><h4>Fetch on an interval</h4></div>
<p class="set-group-hint">Fetching is the only git command Codeman runs that writes, and the only one that runs a repository's own hooks, so nothing runs on a timer until you turn it on &mdash; here, or in App Settings &rarr; Bottom bar. The panel's Fetch button is always just a click.</p>
<div class="set-group-body">
<!-- Live from the case's own last run (session-ui.js: updateGitFetchStatus). -->
<div class="set-row">
<div class="set-row-text">
<span class="set-row-label">Status</span>
<span class="set-row-desc" id="gitFetchStatus" data-i18n-skip hidden></span>
</div>
</div>
<div class="set-row">
<div class="set-row-text">
<span class="set-row-label">Follow the app setting</span>
<span class="set-row-desc">Uses the app-wide switch and interval for this case. Switch it off to pick your own.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="modalGitFetchFollow" checked onchange="app.autoSaveGitFetch()"><span class="slider"></span></label>
</div>
<div class="set-row" id="gitFetchEnabledRow">
<div class="set-row-text">
<span class="set-row-label">Fetch this case</span>
<span class="set-row-desc">Runs on its own even while the app-wide switch is off: a case that turned itself on means exactly that.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="modalGitFetchEnabled" onchange="app.autoSaveGitFetch()"><span class="slider"></span></label>
</div>
<div class="set-row" id="gitFetchIntervalRow">
<div class="set-row-text">
<span class="set-row-label">How often</span>
<span class="set-row-desc">30 sec to 1 hr, timed from the start of a run, so a slow repository does not push the next one out.</span>
</div>
<div class="set-row-actions">
<input type="number" id="modalGitFetchInterval" class="set-num set-num-wide" value="300" min="30" max="3600" step="30" onchange="app.autoSaveGitFetch()">
<span class="set-unit">sec</span>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
@@ -2196,7 +2250,7 @@
<div class="set-row" data-search="git status uncommitted unpushed commit push indicator bottom bar toolbar">
<div class="set-row-text">
<span class="set-row-label">Git status <span class="set-scope">device</span></span>
<span class="set-row-desc">Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only: Codeman never fetches or changes the repository. Not shown for Docker or remote sessions. Off by default.</span>
<span class="set-row-desc">Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` — the one git command here that writes — runs only when you click Fetch in that window, or on the interval below if you turned it on. Not shown for Docker or remote sessions. Off by default.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowGitStatus"><span class="slider"></span></label>
</div>
@@ -2221,6 +2275,20 @@
</div>
<input type="number" id="appSettingsGitStatusTimeout" class="set-num" value="30" min="5" max="120" step="1">
</div>
<div class="set-row" data-search="git fetch interval timer behind remote tracking refs poll run automatically">
<div class="set-row-text">
<span class="set-row-label">Git fetch on an interval <span class="set-scope">synced</span></span>
<span class="set-row-desc">Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the one git command Codeman runs that writes, and the one that runs a repository's own hooks, so nothing runs on a timer until you turn it on. A case can override both the switch and the interval in Session Options → Git.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsGitFetchEnabled"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="git fetch how often seconds between runs interval case override session options">
<div class="set-row-text">
<span class="set-row-label">Git fetch: how often <span class="set-scope">synced</span></span>
<span class="set-row-desc">Seconds between runs for a case that has no override of its own (30 to 3600, default 300). Only a case that is on — here, or in Session Options → Git — is fetched.</span>
</div>
<input type="number" id="appSettingsGitFetchIntervalSeconds" class="set-num" value="300" min="30" max="3600" step="1">
</div>
</div>
</div>
+89
View File
@@ -2685,6 +2685,9 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('modalKeepWarmEnabled').checked =
session.keepWarm?.enabled ?? session.keepWarmEnabled ?? false;
this.updateKeepWarmStatus(sessionId);
// Git: the case's own choice if it has one, otherwise the app setting. updateGitFetchStatus is also
// what SSE calls when a scheduled run lands, so the rows stay current while the modal is open.
this.updateGitFetchStatus(sessionId);
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
@@ -2741,6 +2744,12 @@ Object.assign(CodemanApp.prototype, {
if (respawnTabBtn) respawnTabBtn.style.display = '';
}
// Git is local git only: a remote (SSH) or Docker case answers 'unsupported' on both git routes and is
// never fetched by the interval scheduler, so its Git tab has nothing to say and its rail entry goes
// hidden the same way.
const gitTabBtn = document.querySelector('#sessionOptionsModal .set-rail-item[data-tab="git"]');
if (gitTabBtn) gitTabBtn.style.display = session.remote || session.docker ? 'none' : '';
// Populate Ralph Wiggum form with current session values (skip for external CLI sessions)
if (!isExternalCli) {
const ralphState = this.ralphStates.get(sessionId);
@@ -3022,6 +3031,85 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* The Git tab: the case's last scheduled run, and which values its rows show. A case with no choice of its
* own follows the app setting, so its switch and interval display the app's numbers and go inert — the same
* inert-row pattern as updateKeepWarmStatus above — because changing them here is what makes the case stop
* following. The app numbers come from the settings cache, so what this shows can be one settings load
* behind; the server decides from a fresh read, not from what the page displays.
*/
updateGitFetchStatus(sessionId) {
const statusEl = document.getElementById('gitFetchStatus');
const followBox = document.getElementById('modalGitFetchFollow');
const box = document.getElementById('modalGitFetchEnabled');
const num = document.getElementById('modalGitFetchInterval');
if (!statusEl || !followBox || !box || !num || this.editingSessionId !== sessionId) return;
const session = this.sessions.get(sessionId);
if (!session) return;
const app = this.loadAppSettingsFromStorage() || {};
// No choice of its own on either field means the case follows the app. A partial one — its own switch,
// the app's interval — means it does not, and the row that still follows shows the app's value.
const follows = session.gitFetchEnabled === undefined && session.gitFetchIntervalSeconds === undefined;
const enabled = follows ? app.gitFetchEnabled === true : session.gitFetchEnabled ?? false;
const interval = follows ? app.gitFetchIntervalSeconds ?? 300 : session.gitFetchIntervalSeconds ?? 300;
followBox.checked = follows;
box.checked = enabled;
num.value = interval;
for (const el of [box, num]) {
el.closest('.set-row')?.classList.toggle('set-row-disabled', follows);
el.disabled = follows;
}
const run = session.gitFetch;
const parts = run
? [
`Last run ${this._gitFetchAgo(run.ranAt)}${run.trigger === 'interval' ? ' on the interval' : ' by click'}`,
run.state === 'ok' ? `${run.repos} repositories` : run.state,
run.failed ? `${run.failed} failed` : '',
]
: [follows ? 'Nothing fetched on a timer yet, and this case follows the app setting.' : 'Nothing fetched on a timer yet for this case.'];
statusEl.textContent = parts.filter(Boolean).join(' · ');
statusEl.hidden = false;
},
/** 'just now', '4m', '2h' — the short stamps the tab rows use, measured from a run's start. */
_gitFetchAgo(ts) {
const seconds = Math.max(0, Math.round((Date.now() - ts) / 1000));
if (seconds < 60) return seconds < 10 ? 'just now' : `${seconds}s`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
return `${Math.round(seconds / 3600)}h`;
},
async autoSaveGitFetch() {
if (!this.editingSessionId) return;
const follow = document.getElementById('modalGitFetchFollow').checked;
// `null` on the route means "no choice of your own", which is what following the app setting is.
const body = follow
? { enabled: null, intervalSeconds: null }
: {
enabled: document.getElementById('modalGitFetchEnabled').checked,
intervalSeconds: Math.min(
3600,
Math.max(30, parseInt(document.getElementById('modalGitFetchInterval').value, 10) || 300)
),
};
try {
const data = await this._apiPost(`/api/sessions/${this.editingSessionId}/git-fetch-settings`, body);
const session = this.sessions.get(this.editingSessionId);
const saved = data?.gitFetchSettings;
if (session && saved) {
session.gitFetchEnabled = saved.enabled ?? undefined;
session.gitFetchIntervalSeconds = saved.intervalSeconds ?? undefined;
}
this.updateGitFetchStatus(this.editingSessionId);
this.showToast('Git fetch settings saved', 'success');
} catch (err) {
this.showToast('Failed to save Git fetch settings: ' + err.message, 'error');
}
},
async toggleSessionImageWatcher() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
@@ -3160,6 +3248,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('context-tab').classList.toggle('hidden', tabName !== 'context');
document.getElementById('ralph-tab').classList.toggle('hidden', tabName !== 'ralph');
document.getElementById('summary-tab').classList.toggle('hidden', tabName !== 'summary');
document.getElementById('git-tab').classList.toggle('hidden', tabName !== 'git');
// A switched-to section starts at its own top, not at the scroll offset the
// previous one was left at.
+12
View File
@@ -602,6 +602,11 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsGitStatusTree').checked = settings.gitStatusTree ?? defaults.gitStatusTree ?? true;
document.getElementById('appSettingsGitStatusMaxRepos').value = settings.gitStatusMaxRepos ?? defaults.gitStatusMaxRepos ?? 12;
document.getElementById('appSettingsGitStatusTimeout').value = settings.gitStatusTimeoutSeconds ?? defaults.gitStatusTimeoutSeconds ?? 30;
// Git fetch on an interval: synced, default OFF, interval default 300 s (DEFAULT_GIT_FETCH_INTERVAL_SECONDS).
// Synced means the SERVER decides whether to fetch, so these keys stay out of the per-device display list
// below and are not stripped from a save — a stripped key would never reach the scheduler.
document.getElementById('appSettingsGitFetchEnabled').checked = settings.gitFetchEnabled === true;
document.getElementById('appSettingsGitFetchIntervalSeconds').value = settings.gitFetchIntervalSeconds ?? 300;
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
@@ -2948,6 +2953,13 @@ Object.assign(CodemanApp.prototype, {
// Clamped here and again on the server; an empty or odd value falls back to the default.
gitStatusMaxRepos: Math.min(50, Math.max(1, parseInt(document.getElementById('appSettingsGitStatusMaxRepos').value, 10) || 12)),
gitStatusTimeoutSeconds: Math.min(120, Math.max(5, parseInt(document.getElementById('appSettingsGitStatusTimeout').value, 10) || 30)),
// Clamped to the schema's bounds here too: a value outside them fails the whole save, and these keys
// must survive the strip below because the server's scheduler is what reads them.
gitFetchEnabled: document.getElementById('appSettingsGitFetchEnabled').checked,
gitFetchIntervalSeconds: Math.min(
3600,
Math.max(30, parseInt(document.getElementById('appSettingsGitFetchIntervalSeconds').value, 10) || 300)
),
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
+47 -1
View File
@@ -7151,6 +7151,33 @@ html[data-skin="og"] .welcome-primary:hover {
background: var(--bg-hover);
}
/* A Fetch button cannot be clicked twice while its fetch is running, and is not offered for a session
there is nothing to fetch for (no repository, remote or Docker). */
.btn-icon-sm[disabled] {
color: var(--text-dim);
opacity: 0.45;
cursor: default;
}
.btn-icon-sm[disabled]:hover {
background: none;
}
.btn-icon-sm[aria-busy='true'] {
color: var(--text);
animation: btn-icon-busy 1.1s ease-in-out infinite;
}
@keyframes btn-icon-busy {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 0.9;
}
}
.settings-section {
margin-bottom: 1.25rem;
padding-bottom: 1rem;
@@ -22167,7 +22194,8 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
.git-status-empty,
.git-status-ok,
.git-status-note,
.git-status-more {
.git-status-more,
.git-status-fetch-note {
color: var(--text-muted);
padding: 0.25rem 0;
}
@@ -22176,6 +22204,24 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
color: rgb(34, 197, 94);
}
/* The last Fetch's result: a summary line, then a row per repository it could not fetch. */
.git-status-fetch-note {
margin-top: 0.6rem;
padding-top: 0.4rem;
border-top: 1px solid var(--border);
}
.git-status-fetch-summary {
margin-bottom: 0.2rem;
}
.git-status-fetch-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.1rem 0;
}
.git-status-branchline {
display: flex;
flex-wrap: wrap;
+38 -7
View File
@@ -1,18 +1,22 @@
/**
* @fileoverview `GET /api/sessions/:id/git-status`: what the session's workspace has not committed or
* pushed (src/git-workspace-status.ts), for the bottom-bar Git indicator and its panel. The answer is
* @fileoverview The Git panel's routes: `GET /api/sessions/:id/git-status` (what the session's workspace
* has not committed or pushed, src/git-workspace-status.ts) and `POST /api/sessions/:id/git-fetch` (the
* panel's Fetch button, src/git-fetch.ts), for the bottom-bar Git indicator and its window. The answer is
* an overview: the enclosing repository, or each repository found below a folder that holds several
* projects (see `getGitWorkspaceOverview` for exactly which).
*
* Read-only and offline: it never fetches and never runs a git write command. A remote (SSH) or
* Docker session is not inspected and answers `state: 'unsupported'`, and neither is any repository at or
* inside a Docker case workspace (a container can write there, and git here would run on the host). Ownership goes through
* Read-only and offline: the status route never fetches and never runs a git write command. Fetching is a
* separate, user-triggered path (`POST /api/sessions/:id/git-fetch`, src/git-fetch.ts) that the panel calls
* only when the user clicks Fetch. A remote (SSH) or Docker session is not inspected and answers
* `state: 'unsupported'` on both routes, and neither is any repository at or inside a Docker case
* workspace (a container can write there, and git here would run on the host). Ownership goes through
* `findSessionOrFail`, like every session-scoped route.
*/
import type { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { redactGitCredentials } from '../../git-clone.js';
import { emptyReport, fetchGitWorkspaceOverview, type GitFetchReport } from '../../git-fetch.js';
import { readDockerCases } from '../../docker-hosts.js';
import { getDataDir } from '../../config/instance.js';
import { findSessionOrFail } from '../route-helpers.js';
@@ -30,8 +34,12 @@ import {
} from '../../git-workspace-status.js';
import type { SessionPort } from '../ports/index.js';
/** Host paths of every Docker case workspace: repositories at or inside these are never inspected. */
const defaultDockerWorkspaces = async (): Promise<string[]> =>
/**
* Host paths of every Docker case workspace: repositories at or inside these are never inspected.
* Exported so the interval scheduler (src/git-fetch-scheduler.ts) uses the SAME list — the Docker rule has
* to be one rule, checked by every path that could run git.
*/
export const defaultDockerWorkspaces = async (): Promise<string[]> =>
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
/**
@@ -119,4 +127,27 @@ export function registerGitStatusRoutes(
);
}
});
// Fetch, and only because the user clicked Fetch in the panel (src/git-fetch.ts): one
// `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot
// disagree about which repositories belong to the workspace. This is the only git command Codeman runs
// that writes, and it writes remote-tracking refs only — nothing commits, checks out or merges, so an
// agent working in the same repository is not disturbed. Remote and Docker sessions answer `unsupported`
// like the status route, and a repository a container can write to is refused before any git runs.
app.post('/api/sessions/:id/git-fetch', async (req): Promise<ApiResponse<GitFetchReport>> => {
const { id } = req.params as { id: string };
const query = req.query as { maxRepos?: unknown; timeout?: unknown };
const limits = limitsFrom(query);
const session = findSessionOrFail(ctx, id, req);
if (session.remote) return { success: true, data: emptyReport('unsupported', { reason: 'remote' }) };
if (session.docker) return { success: true, data: emptyReport('unsupported', { reason: 'docker' }) };
return {
success: true,
data: await fetchGitWorkspaceOverview(session.workingDir, {
git,
...limits,
dockerWorkspaces: await dockerWorkspaces(),
}),
};
});
}
+33
View File
@@ -57,6 +57,7 @@ import {
AutoCompactSchema,
AutoResumeSchema,
KeepWarmSchema,
GitFetchSettingsSchema,
PinSessionSchema,
ImageWatcherSchema,
FlickerFilterSchema,
@@ -3608,6 +3609,38 @@ export function registerSessionRoutes(
};
});
// ========== Git fetch on an interval ==========
// Per-case override for the fetch interval (src/git-fetch-scheduler.ts). `null` on either field clears
// the case's choice so the app-wide setting applies again, and a field left out of the body leaves the
// other half as it was: the Session Options rows save on their own. Refused for a remote or Docker
// session, exactly as the Git panel excludes them — a container can write to the repository it mounts,
// so git there would run that repository's own configuration on the host.
app.post('/api/sessions/:id/git-fetch-settings', async (req) => {
const { id } = req.params as { id: string };
const body = parseBody(GitFetchSettingsSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id, req);
if (session.remote || session.docker) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git fetch does not run for remote or Docker sessions');
}
const enabled = 'enabled' in body ? (body.enabled ?? undefined) : session.gitFetchEnabled;
const intervalSeconds =
'intervalSeconds' in body ? (body.intervalSeconds ?? undefined) : session.gitFetchIntervalSeconds;
session.setGitFetchSettings(enabled, intervalSeconds);
persistAndBroadcastSession(ctx, session);
return {
success: true,
data: {
gitFetchSettings: {
enabled: session.gitFetchEnabled ?? null,
intervalSeconds: session.gitFetchIntervalSeconds ?? null,
},
},
};
});
// ========== Auto-Resume (usage-limit pause) ==========
app.post('/api/sessions/:id/auto-resume', async (req) => {
+19
View File
@@ -1488,6 +1488,16 @@ export const SettingsUpdateSchema = z
promptCacheKeepWarm: z.boolean().optional(),
/** Hours after the last real prompt during which keep-alives are sent (default 2, DEFAULT_KEEP_WARM_MAX_HOURS). */
promptCacheKeepWarmMaxHours: z.number().min(0.5).max(24).optional(),
/**
* Git fetch on an interval (src/git-fetch-scheduler.ts): `git fetch` for the repositories the Git panel
* lists, run on a timer instead of only on the panel's Fetch click. SYNCED, default OFF, because the
* server is what runs it: `git fetch` is the only git command Codeman runs that writes, and the only
* one that runs a repository's own hooks, so nothing runs on a timer until the user turns this on.
* Cap in seconds between runs (30 to 3600, default 300). A case can override both in Session Options
* → Git; `null` there clears the override.
*/
gitFetchEnabled: z.boolean().optional(),
gitFetchIntervalSeconds: z.number().int().min(30).max(3600).optional(),
/**
* Spawned tabs follow their parent: a session created by another one keeps
* following its parent's tab (into its group, moving with it) until moved by
@@ -1928,6 +1938,15 @@ export const AutoResumeSchema = z.object({
/** `POST /api/sessions/:id/keep-warm`: `null` clears the session's choice so the app-wide default applies. */
export const KeepWarmSchema = z.object({ enabled: z.boolean().nullable() });
/**
* `POST /api/sessions/:id/git-fetch-settings`: `null` clears the session's choice so the app-wide default
* applies. Both fields are optional, so a row that saves on its own does not clear the other.
*/
export const GitFetchSettingsSchema = z.object({
enabled: z.boolean().nullable().optional(),
intervalSeconds: z.number().int().min(30).max(3600).nullable().optional(),
});
/** POST /api/sessions/:id/pin (COD-139) — explicit pin state for idempotency. */
export const PinSessionSchema = z.object({
pinned: z.boolean(),
+56
View File
@@ -100,6 +100,7 @@ import { getLifecycleLog } from '../session-lifecycle-log.js';
import {
applyWorkspaceHooks,
pruneAgentSessionPreambles,
readGitFetchSettings,
readKeepWarmSettingsOrThrow,
readPlanUsageTelemetryEnabled,
removeAgentSessionPreamble,
@@ -123,6 +124,9 @@ import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
import { approvalInbox } from './approval-inbox.js';
import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js';
import { PromptCacheKeepWarmService } from '../prompt-cache-keepwarm.js';
import { fetchGitWorkspaceOverview } from '../git-fetch.js';
import { GitFetchScheduler } from '../git-fetch-scheduler.js';
import { defaultDockerWorkspaces } from './routes/git-status-routes.js';
import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js';
import { detectUsageLimitPause } from '../usage-limit-patterns.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
@@ -293,6 +297,7 @@ import {
STATS_COLLECTION_INTERVAL_MS,
INACTIVITY_TIMEOUT_MS,
CRON_TICK_INTERVAL,
GIT_FETCH_TICK_INTERVAL_MS,
} from '../config/server-timing.js';
/**
@@ -389,6 +394,28 @@ export class WebServer extends EventEmitter {
},
log: (message) => console.log(message),
});
// Git fetch on an interval (git-fetch-scheduler.ts): the due-tracking lives in the service; the server
// lends it the facts a decision needs and stores what it reports on the session. A field initializer for
// the same reason as the keep-warm one above, and the scheduler reads the app setting FRESH on every tick
// — that is what makes a live toggle take effect on the next tick, and what makes turning it off actually
// stop the fetching. Default OFF, so an install that never enabled it spawns nothing at all.
private readonly gitFetchScheduler = new GitFetchScheduler({
sessionIds: () => this.sessions.keys(),
getSession: (id) => this.sessions.get(id),
readSettings: readGitFetchSettings,
// The same Docker list the panel's routes use: the rule has to be one rule.
dockerWorkspaces: defaultDockerWorkspaces,
fetch: (cwd, opts) => fetchGitWorkspaceOverview(cwd, opts),
publish: (id, status) => {
const session = this.sessions.get(id);
if (!session?.setGitFetchStatus(status)) return;
// No new SSE event: the status rides the session state the page already listens for, and the Git
// panel polls on its own clock. Persisted, because what a run found is the panel's current truth.
this.persistSessionState(session);
this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
},
log: (message) => console.log(message),
});
/**
* Wake-on-LAN registry, returned by `registerSessionRoutes`. Held for its LIFETIME
* only — `drop()` on session cleanup, `stop()` on shutdown. Waking from here would
@@ -1242,6 +1269,21 @@ export class WebServer extends EventEmitter {
registerRebootRestoreRoutes(this.app, ctx);
registerReadMyMindRoutes(this.app, ctx);
registerGitStatusRoutes(this.app, ctx);
// Git fetch on an interval: this is only the wake-up, the scheduler itself decides what is due. Off in
// tests for the same reason as the polls below — a test drives `tick()` directly instead of waiting on
// a timer the harness would have to keep alive. The setting is read FRESH inside `tick()`, so turning
// it on takes effect on the next tick and turning it off actually stops the fetching.
if (!this.testMode) {
this.cleanup.setInterval(
() => {
this.gitFetchScheduler.tick().catch((err) => {
console.error('[git fetch] interval tick failed:', getErrorMessage(err));
});
},
GIT_FETCH_TICK_INTERVAL_MS,
{ description: 'git fetch interval checker' }
);
}
registerStatusTelemetryRoutes(this.app, ctx);
registerSystemRoutes(this.app, ctx);
registerCaseRoutes(this.app, ctx);
@@ -1499,6 +1541,9 @@ export class WebServer extends EventEmitter {
): Promise<void> {
const session = this.sessions.get(sessionId);
this.keepWarmService.remove(sessionId);
// A closed case takes its place in the fetch schedule with it: otherwise a detached-and-forgotten id
// would still count as tracked, and its next run would land on a session that no longer exists.
this.gitFetchScheduler.remove(sessionId);
const pinned = session?.pinned === true || this.store.getSession(sessionId)?.pinned === true;
if (coordinateLayout && session && killMux && !pinned) {
return this.tabLayouts.runSessionDeletion([{ id: sessionId, owner: session.owner }], () =>
@@ -3540,6 +3585,9 @@ export class WebServer extends EventEmitter {
if (saved.autoClearEnabled !== undefined || saved.autoClearThreshold !== undefined) {
session.setAutoClear(saved.autoClearEnabled ?? false, saved.autoClearThreshold);
}
if (saved.gitFetchEnabled !== undefined || saved.gitFetchIntervalSeconds !== undefined) {
session.setGitFetchSettings(saved.gitFetchEnabled, saved.gitFetchIntervalSeconds);
}
if (saved.autoResumeEnabled) {
// The stamp is re-armed by default, because a Codeman restart leaves the
// limit footer un-reprinted and dropping it there would strand the pause.
@@ -3762,6 +3810,11 @@ export class WebServer extends EventEmitter {
// below, a future `expiresAt` re-arms the timer once the pane is attached.
lastRealPromptAt: savedState?.lastRealPromptAt,
keepWarmEnabled: savedState?.keepWarmEnabled,
// The case's own fetch switch and interval, carried over rather than reset to the app default:
// the choice is the user's and outlives the run that made it. The scheduler reads them fresh
// on its next tick, so a restored case is back on its own schedule without a restart.
gitFetchEnabled: savedState?.gitFetchEnabled,
gitFetchIntervalSeconds: savedState?.gitFetchIntervalSeconds,
// Conversations this pane provably owned, oldest first. Its tail is
// the conversation the CLI was on when the server stopped, which is
// what a re-attach must point the viewer at instead of the launch id.
@@ -4233,6 +4286,9 @@ export class WebServer extends EventEmitter {
sessionWaits.cancelEverything();
approvalInbox.stop();
this.keepWarmService.stop();
// Stops the fetch sweep from starting another run while the server is closing; a git process already
// running is left to finish, which is what a fetch does to a repository nothing else is touching.
this.gitFetchScheduler.stop();
// A post within the debounce window before shutdown would otherwise not reach disk.
// Flushed again after `app.close()` below, for a post that lands in between.
agentInboxPersistence.flush();