feat(cli): offline mode β direct SQLite reads when the server is down, explicit refusals otherwise#214
Conversation
β¦, explicit refusals otherwise
With the dashboard server stopped, the CLI previously refused everything.
Now every command that can run correctly without the server does, and the
rest say exactly why they can't:
- Read-only fallback: sessions, session <id>, agents, events, kanban,
stats, pricing (list), alerts (list), rules, export, and doctor fall
back to reading data/dashboard.db directly under an explicit banner
("β Offline mode β β¦ data as of the last capture; live capture needs
the server: ccam start"). The offline data providers produce the same
shapes as the API responses and feed the exact renderers the online
path uses (extracted renderStats/renderSessions/renderAgents/
renderEvents/renderKanban), so output is identical. session <id> shows
everything but the cost line, which is flagged as server-only; offline
exports carry "exported_offline": true; offline doctor reports the
server as down plus DB size/row counts and hook installation read from
~/.claude/settings.json.
- Server-only refusals with reasons: tail (live capture), analytics /
workflows / runs / cost (server-side aggregation and pricing math),
alerts ack, webhooks, pricing mutations, import, cleanup, clear-data,
reinstall-hooks, info, health β each prints "No offline fallback for
this command: <reason>" above the standard start guidance.
- Mechanics: api() now throws ServerDownError instead of exiting; the
dispatcher routes it to the command's offline handler or the reasoned
refusal. The SQLite connection comes from the repo's better-sqlite3
(node:sqlite fallback), never creates a file, is SELECT-only by
construction, and is deliberately opened WITHOUT SQLite's readonly
flag: a strict readonly connection cannot attach a live WAL's
shared-memory index and silently reads the stale pre-WAL state β a
normal connection under WAL reads consistently (bug found by test).
Tests: CLI suite grows to 46 β an offline describe block covers the
fallback banner + parity for every offline command, the cost/tail/
analytics/pricing-set/clear-data refusals (data verified intact), and
offline export markers. Full server suite green.
Docs: docs/CLI.md gains an Offline Mode section with the capability
matrix; README + VN + CN notes; ARCHITECTURE bin/ccam.js row expanded;
wiki CLI section gains the lifecycle/offline paragraph with zh/vi/ko
translations (cache wiki-v33 -> v34, i18n ?v=24 -> 25).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146oeseRLX17wL3iTqc1trq
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
There was a problem hiding this comment.
Code Review
This pull request introduces an "Offline Mode" for the ccam CLI, allowing read-only commands (such as stats, sessions, agents, events, kanban, rules, alerts, export, and doctor) to fall back to reading the SQLite database directly when the dashboard server is down. Commands requiring server-side logic or mutations are rejected with specific reasons. The feedback highlights two issues in bin/ccam.js: first, a date comparison bug in the offline stats query where comparing SQLite's created_at format with an ISO string fails lexicographically; second, a usability issue in the offline session command where partial session ID lookups are not supported, and subsequent queries do not use the resolved full session UUID.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| events_today: one( | ||
| "SELECT COUNT(*) AS n FROM events WHERE created_at >= ?", | ||
| midnight.toISOString() | ||
| ), |
There was a problem hiding this comment.
In SQLite, created_at is stored as a TEXT field in the format YYYY-MM-DD HH:MM:SS (without the T separator or timezone suffix). Comparing it directly against midnight.toISOString() (which produces a string like YYYY-MM-DDTHH:MM:SS.sssZ) will fail lexicographically because the space character ( ) is smaller than T. This results in events_today always returning 0.
Convert the ISO string to the database's format by replacing T with a space and slicing to 19 characters.
| events_today: one( | |
| "SELECT COUNT(*) AS n FROM events WHERE created_at >= ?", | |
| midnight.toISOString() | |
| ), | |
| events_today: one( | |
| "SELECT COUNT(*) AS n FROM events WHERE created_at >= ?", | |
| midnight.toISOString().replace("T", " ").slice(0, 19) | |
| ), |
| const rows = db.all("SELECT * FROM sessions WHERE id = ?", id); | ||
| if (!rows.length) { | ||
| console.error(c.red(`β Session not found: ${id}`)); | ||
| process.exit(1); | ||
| } | ||
| const s = rows[0]; | ||
| console.log(`${c.bold(s.name || s.id)} ${c.dim(`(${s.id})`)}`); | ||
| console.log( | ||
| `status ${colorStatus(s.status)} Β· model ${fmtModel(s.model)} Β· ` + | ||
| `duration ${fmtDuration(s.started_at, s.ended_at)} Β· cwd ${s.cwd || "-"}` | ||
| ); | ||
| console.log(c.dim("cost: requires the server (pricing math runs server-side)")); | ||
| const agents = db.all("SELECT * FROM agents WHERE session_id = ? ORDER BY started_at ASC", id); | ||
| if (agents.length) { | ||
| console.log(`\n${c.bold("Agents")} (${agents.length})`); | ||
| const byParent = {}; | ||
| for (const a of agents) (byParent[a.parent_agent_id || ""] ||= []).push(a); | ||
| const walk = (parentId, depth) => { | ||
| for (const a of byParent[parentId] || []) { | ||
| const pad = " ".repeat(depth + 1); | ||
| const tool = a.current_tool ? c.cyan(` [${a.current_tool}]`) : ""; | ||
| console.log( | ||
| `${pad}${colorStatus(a.status)} ${a.type === "main" ? c.bold(a.name) : a.name}${tool} ${c.dim(fmtDuration(a.started_at, a.ended_at))}` | ||
| ); | ||
| walk(a.id, depth + 1); | ||
| } | ||
| }; | ||
| walk("", 0); | ||
| } | ||
| const events = db.all( | ||
| "SELECT * FROM events WHERE session_id = ? ORDER BY created_at DESC LIMIT 10", | ||
| id | ||
| ); |
There was a problem hiding this comment.
When retrieving session details offline, the user might provide a partial session ID (e.g., the 8-character prefix shown in ccam sessions). If they do, the exact match id = ? will fail. Furthermore, even if the query is updated to support prefix matching, using the user-provided id directly in the subsequent agents and events queries will fail to return any results because those tables store the full session UUID.
To fix this, support prefix matching for the session lookup, and then use the resolved session's full ID (s.id) for the agents and events queries.
const rows = db.all("SELECT * FROM sessions WHERE id = ? OR id LIKE ?", id, id + "%");
if (!rows.length) {
console.error(c.red("β Session not found: " + id));
process.exit(1);
}
const s = rows[0];
console.log(c.bold(s.name || s.id) + " " + c.dim("(" + s.id + ")"));
console.log(
"status " + colorStatus(s.status) + " Β· model " + fmtModel(s.model) + " Β· " +
"duration " + fmtDuration(s.started_at, s.ended_at) + " Β· cwd " + (s.cwd || "-")
);
console.log(c.dim("cost: requires the server (pricing math runs server-side)"));
const agents = db.all("SELECT * FROM agents WHERE session_id = ? ORDER BY started_at ASC", s.id);
if (agents.length) {
console.log("\n" + c.bold("Agents") + " (" + agents.length + ")");
const byParent = {};
for (const a of agents) (byParent[a.parent_agent_id || ""] ||= []).push(a);
const walk = (parentId, depth) => {
for (const a of byParent[parentId] || []) {
const pad = " ".repeat(depth + 1);
const tool = a.current_tool ? c.cyan(" [" + a.current_tool + "]") : "";
console.log(
pad + colorStatus(a.status) + " " + (a.type === "main" ? c.bold(a.name) : a.name) + tool + " " + c.dim(fmtDuration(a.started_at, a.ended_at))
);
walk(a.id, depth + 1);
}
};
walk("", 0);
}
const events = db.all(
"SELECT * FROM events WHERE session_id = ? ORDER BY created_at DESC LIMIT 10",
s.id
);While the server is down its dead-session liveness reap is not running, so the database can hold active/waiting rows for sessions that exited after the server stopped β and offline mode would have printed those stale statuses as-is. Offline handlers now run the SAME process-liveness probe the server's watchdog uses (server/lib/session-liveness.js, ps/lsof or /proc) and correct the DISPLAYED status of any active session whose cwd has no running claude process β sessions, session <id>, agents, kanban, and the stats distribution all correct consistently (agent rows follow their session), each with a footnote: "β» N session(s) displayed as completed by the process-liveness probe". The database is never written; persisting the correction stays the server's job. Where the probe cannot answer (Windows, containers, DASHBOARD_LIVENESS_PROBE=0) a "Statuses are as stored" caveat is printed instead β and only when active rows are actually shown, so quiet DBs stay quiet. Tests: 48 CLI tests β new cases assert the caveat path (probe disabled) and the correction path (real probe, skipped automatically on platforms where the probe is unavailable), including that the stored DB status remains untouched after a corrected display. Docs: offline-correctness paragraph in docs/CLI.md, clauses in README + VN + CN. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146oeseRLX17wL3iTqc1trq
Greatly enhanced ccam output for real command-line UX while staying dependency-free and 100% script-safe: - Color system: SGR helpers with proper nesting (bold inside color), on for TTYs, off when piped, NO_COLOR / --no-color (anywhere on the command line) to disable, FORCE_COLOR / CCAM_COLOR=1 to force. - Box-drawn tables: bold headers, dim borders, right-aligned numeric columns, terminal-width fitting (widest column clipped with an ellipsis so frames never wrap), "(no rows)" placeholder. - Status icons + colors everywhere a status appears (β active, β working, β waiting, β completed, β error, β¦ abandoned); event types get stable per-type colors across events, tail, and session. - Inline bar charts: sessions-by-status (stats), top tools and agent types (analytics), per-model cost breakdown (cost); non-zero values always render at least one block. - Real trees: ββ/ββ with continuation rails for the agent hierarchy in session <id>; kanban lanes render colored headers with branch rows. - Session detail is a metadata card (aligned key/value) shared by the online and offline paths so both render byte-identically; sessions tables gain a relative "Updated" column. - ccam start animates a braille spinner on a TTY (dot trail piped); help is colorized with section sidebars; new version command (--version / -v) reads package.json. Data correctness is unchanged and conservative: offline liveness correction, staleness caveats, and server-only refusals all preserved; `info` remains raw JSON for jq. Tests: 52 CLI tests (4 new: version, --no-color placement, no-ANSI when piped, box borders + Updated column); full server suite 631/631. Docs: docs/CLI.md output section rewritten (color rules table, charts, trees, version), README + VN + CN CLI sections, ARCHITECTURE row, wiki feature card + CLI intro in EN/zh/vi/ko with sw cache bump (wiki-v35, i18n ?v=26). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146oeseRLX17wL3iTqc1trq
Summary
Stacked on #213 (merge that first; this PR's base is
feat/ccam-start-indicator). With the server stopped, the CLI now does as much as can be done correctly without it β and says exactly why for the rest.Offline capability matrix
data/dashboard.dbreads (withβ Offline modebanner)sessionsΒ·session <id>(all but the cost line) Β·agentsΒ·eventsΒ·kanbanΒ·statsΒ·pricing(list) Β·alerts(list) Β·rulesΒ·export("exported_offline": true) Β·doctor(offline variant)tail(live capture) Β·analytics/workflows/runs/cost(server-side aggregation + pricing math) Β·alerts ackΒ·webhooksΒ·pricing set/delete/resetΒ·importΒ·cleanupΒ·clear-dataΒ·reinstall-hooksΒ·infoΒ·healthMechanics
api()throwsServerDownErrorinstead of exiting; the dispatcher routes to the command's offline handler or a reasoned refusal (No offline fallback for this command: <reason>+ the standardccam startguidance from feat(cli): server up/down indicator + ccam start / ccam statusΒ #213).renderStats/Sessions/Agents/Events/Kanban) β identical output.better-sqlite3(falls back tonode:sqlite), never creates a file, SELECT-only by construction, and deliberately opened without SQLite's readonly flag β a strict readonly connection can't attach a live WAL's shared-memory index and silently reads stale pre-WAL state (caught by test); a normal connection under WAL reads consistently.Tests & docs
clear-data --yesrefused offline with data verified intact, offline-export markers.docs/CLI.mdOffline Mode section + matrix; README + VN + CN notes; ARCHITECTURE row; wiki CLI section paragraph with zh/vi/ko translations (cachewiki-v34, i18n?v=25).Type of Change
Terminal UI Overhaul (second commit)
9a2ab7eupgrades ccam's output into a full terminal UI β still dependency-free and 100% script-safe:NO_COLOR/--no-color(anywhere on the command line) disable,FORCE_COLOR/CCAM_COLOR=1force.(no rows)placeholder.β active,β working,β waiting,β completed,β error,β¦ abandoned; per-type event colors acrossevents/tail/session.stats), top tools + agent types (analytics), per-model breakdown (cost).ββ/ββwith continuation rails for the agent hierarchy; kanban lanes with branch rows.ccam startspinner on a TTY (dot trail when piped); colorizedhelp; newccam version/--version/-v.Data correctness is untouched: offline liveness correction, staleness caveats, and server-only refusals all preserved;
infostays raw JSON forjq.Tests: 52 CLI tests (4 new: version,
--no-colorplacement, no-ANSI-when-piped, box borders + Updated column); full backend suite 631/631. Docs:docs/CLI.mdoutput section rewritten (color-rules table), README + VN + CN,ARCHITECTURE.md, wiki feature card + CLI intro in EN/zh/vi/ko with service-worker cache bump (wiki-v35, i18n?v=26).How to Test
ccam sessions/stats/kanban/doctorβ offline banner + real data;ccam cost/tail/clear-data --yesβ reasoned refusal.npm run test:serverβ 631/631.ccam stats,ccam kanban,ccam session <id>,ccam analytics,ccam costβ colored tables/charts/trees; pipe any of them tocatand the output is plain text.Checklist
ποΈ CLA Assistantbot will prompt me on my first PR)npm test)npm run format:check)π€ Generated with Claude Code
https://claude.ai/code/session_0146oeseRLX17wL3iTqc1trq