Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
fb24118
Keep terminal mounted with an exit banner when a session dies
HaydnG Jun 1, 2026
0b153cd
docs: guide de personnalisation des couleurs (theme clair facon Cowor…
JlpFQ2A Jun 11, 2026
fae085a
fix: allow text selection in mouse-tracking sessions on macOS (#81)
Bono2007 Aug 1, 2026
d846d35
fix(terminal): don't hijack Space during IME composition (#75)
HAN-oQo Aug 1, 2026
94a7ec0
fix: shorten project paths correctly on Windows (#83)
Davidb-2107 Aug 1, 2026
868c47e
perf(app): poll active sessions adaptively (3s active, 30s idle) (#64)
JeanBaptisteRenard Aug 1, 2026
63eebe0
Add Arch/Manjaro (pacman) bundling target (#53)
thfaix Aug 1, 2026
10b80e2
Ignore the worktree option when resuming a session (fix at the source)
ymajoros Jun 4, 2026
54d0edb
Make copy actually copy on Wayland (main-process clipboard + OSC 52) …
ymajoros Aug 1, 2026
1c0c8c5
ci: pin the Windows runner to windows-2022 (#85)
abasiri Aug 1, 2026
a7698f4
feat(permissions): offer Claude Code's `auto` permission mode
abasiri Aug 1, 2026
68686fe
fix(build): stop build:mac from stripping its own signature; name art…
abasiri Aug 1, 2026
a791cdd
Reconcile cache with filesystem on get-projects so sessions stop goin…
ymajoros Aug 1, 2026
f92a6de
perf(scheduler): resolve projectPath from cache_meta instead of re-re…
JeanBaptisteRenard Aug 1, 2026
31c3029
fix(security): build claude/scheduler commands as argv instead of she…
joeytwiddle Aug 1, 2026
35a25e5
fix(main): requestSingleInstanceLock to prevent PTY loss on replace (…
JeanBaptisteRenard Aug 1, 2026
43ab0d0
feat(stats): read the usage API's `limits` array so per-model windows…
abasiri Aug 1, 2026
57dee95
feat: add context and quota gauges to the status bar (#72)
Flaykz Aug 1, 2026
75cc404
feat(status-bar): one quota bar per limit window; drop the context gauge
abasiri Aug 1, 2026
b2489df
fix(deps): bump electron-updater to ^6.8.9 for CVE-2026-54673 (#86)
anupamme Aug 1, 2026
cd8495d
fix(deps): bump ws to ^8.21.1 (GHSA-58qx-3vcg-4xpx, GHSA-96hv-2xvq-fx4p)
abasiri Aug 1, 2026
4df70a7
fix(dmg): light installer background so the icon labels are legible
abasiri Aug 1, 2026
e5e27df
v0.0.31: usage gauges, auto permission mode, security and build fixes
abasiri Aug 1, 2026
d5798af
fix(dialogs): remove keydown listener on every dialog close path
abasiri Aug 1, 2026
7d619a6
fix(sessions): show last-message time instead of file mtime; compact …
abasiri Aug 1, 2026
fea74b8
fix(status-bar): match quota gauge font size to the rest of the statu…
abasiri Aug 1, 2026
7eabfb2
Merge pull request #58 from HaydnG/fix/show-exit-banner-instead-of-si…
abasiri Aug 2, 2026
16f3f6a
Merge branch 'main' of https://github.com/doctly/switchboard
abasiri Aug 2, 2026
e39f251
fix(ui): shorten session exit banner to just the exit code
abasiri Aug 2, 2026
4529aeb
chore: merge upstream/main (v0.0.31) into fork
JeanBaptisteRenard Aug 3, 2026
d35270f
feat(scheduler): default headless scheduled tasks to the auto permiss…
JeanBaptisteRenard Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ jobs:
include:
- os: macos-14
platform: mac
# Pinned to windows-2022: `windows-latest` migrated to the windows-2025
# image, where node-gyp fails to detect the VS toolchain when rebuilding
# native modules (node-pty / better-sqlite3) → "Could not find any Visual
# Studio installation to use". 2022 ships a node-gyp-discoverable VS2022.
# Pinned, not `windows-latest`: that label has rolled over to Windows
# Server 2025, whose image no longer ships the Visual Studio C++
# toolchain node-gyp needs. On it, electron-builder's install-app-deps
# dies with "Could not find any Visual Studio installation to use"
# while rebuilding node-pty, so the Windows build never produces an
# artifact. 2022 still carries the toolchain.
- os: windows-2022
platform: win
# Linux is split per-arch to avoid electron-builder bundling native
Expand Down Expand Up @@ -53,6 +55,10 @@ jobs:
- name: Install setuptools (node-gyp distutils fix)
run: python -m pip install setuptools

- name: Install pacman build tooling (Linux)
if: matrix.platform == 'linux'
run: sudo apt-get update && sudo apt-get install -y libarchive-tools

- name: Install dependencies
run: npm ci

Expand Down Expand Up @@ -86,6 +92,7 @@ jobs:
dist/*.exe.blockmap
dist/*.AppImage
dist/*.deb
dist/*.pacman
dist/latest*.yml
if-no-files-found: ignore

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ dist/
*.db-shm
public/codemirror-bundle.js
.DS_Store
.idea/
*.pem
.env
.cache/
Expand Down
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ Grab the latest release for your platform:

- **macOS**: `.dmg` (Apple Silicon & Intel)
- **Windows**: `.exe` installer
- **Linux**: `.AppImage` or `.deb`
- **Linux**: `.AppImage`, `.deb`, or `.pacman` (Arch/Manjaro)

## Prerequisites

Expand Down Expand Up @@ -156,11 +156,30 @@ task build # AppImage + deb (Linux)
# npm equivalents:
npm run build:mac # DMG + zip (arm64 + x64)
npm run build:win # NSIS installer (x64 + arm64)
npm run build:linux # AppImage + deb (x64 + arm64)
npm run build:linux # AppImage + deb + pacman (x64 + arm64)
```

Output goes to `dist/`.

### Building on Arch / Manjaro

The `deb` and `pacman` targets are built via the `fpm` binary bundled by
electron-builder, which links against `libcrypt.so.1`. Arch ships `libxcrypt`
without that legacy ABI, so install the compat shim once:

```bash
sudo pacman -S libxcrypt-compat
```

`AppImage` builds without it.

The pacman package is published as **`switchboard-doctly`** rather than
`switchboard` because the Arch `extra` repo already ships a package named
`switchboard` (elementary OS's Pantheon Control Center). Renaming avoids the
file-conflict that would block installation alongside it. The app itself is
still called Switchboard everywhere users see it — only the package identity
changes. Uninstall later with `sudo pacman -R switchboard-doctly`.

## Releasing

Releases are driven by git tags:
Expand Down
Binary file modified build/dmg-background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified build/dmg-background@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/icons/128x128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/icons/16x16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/icons/256x256.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/icons/32x32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/icons/48x48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/icons/512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/icons/64x64.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
55 changes: 52 additions & 3 deletions claude-auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,7 @@ function readFromKeychain() {
try {
const service = getKeychainServiceName();
const user = process.env.USER || os.userInfo().username;
// execFileSync (no shell) so the username and service name are never
// interpolated into a shell string — prevents injection via $USER or
// a crafted CLAUDE_CONFIG_DIR that produces a malicious service name.
// execFileSync (no shell) so $USER can't be interpolated into a command string
const json = execFileSync(
'security',
['find-generic-password', '-a', user, '-w', '-s', service],
Expand Down Expand Up @@ -98,13 +96,64 @@ function mapBucket(apiUsage, apiKey, usageKey, usage) {
}
}

/**
* Map the API's `limits` array into a renderable list.
*
* The per-model top-level buckets this file used to read — seven_day_sonnet,
* seven_day_opus, and a set of codenamed ones — now come back `null`. The live
* data moved into `limits`, which is self-describing: each row carries its own
* `kind`, `percent`, `resets_at`, and, for model-scoped windows, the model's
* display name. Reading that means new models show up on their own instead of
* needing a new key added here every time one ships.
*/
function mapLimits(apiUsage, usage) {
const rows = Array.isArray(apiUsage.limits) ? apiUsage.limits : [];
const out = [];
for (const l of rows) {
if (!l || l.percent === null || l.percent === undefined) continue;
const model = l.scope?.model?.display_name || null;
const label = l.kind === 'session' ? 'Current session'
: l.kind === 'weekly_all' ? 'Week (all models)'
: model ? `Week (${model})`
: 'Week';
out.push({
kind: l.kind || null,
label,
model,
percent: Math.floor(l.percent),
reset: l.resets_at ? formatResetTime(l.resets_at) : null,
severity: l.severity || 'normal',
});
}
if (out.length) usage.limits = out;
}

function transformUsageResponse(apiUsage) {
if (!apiUsage) return {};
const usage = {};
// Legacy flat keys — kept because existing callers read usage.session /
// usage.weekAll directly. five_hour and seven_day are still populated by the
// API; the two model-specific ones are not, and are left in only so an older
// response shape still maps.
mapBucket(apiUsage, 'five_hour', 'session', usage);
mapBucket(apiUsage, 'seven_day', 'weekAll', usage);
mapBucket(apiUsage, 'seven_day_sonnet', 'weekSonnet', usage);
mapBucket(apiUsage, 'seven_day_opus', 'weekOpus', usage);

mapLimits(apiUsage, usage);

// Backfill the legacy keys from `limits` if the flat buckets ever go null too,
// so the status bar and any other flat-key reader keep working.
for (const l of usage.limits || []) {
if (l.kind === 'session' && usage.session === undefined) {
usage.session = l.percent;
if (l.reset) usage.sessionReset = l.reset;
}
if (l.kind === 'weekly_all' && usage.weekAll === undefined) {
usage.weekAll = l.percent;
if (l.reset) usage.weekAllReset = l.reset;
}
}
return usage;
}

Expand Down
49 changes: 37 additions & 12 deletions db.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ const path = require('path');
const os = require('os');

// SWITCHBOARD_DATA_DIR lets dev/agent runs use a separate DB from the
// installed AppImage so they don't race on session_cache. Default stays
// ~/.switchboard so existing installs keep working. Resolve env var at
// require-time (any later mutation would be ignored).
// installed AppImage so they don't race on session_cache (main.js also
// isolates Electron userData / the single-instance lock off the same
// variable). Default stays ~/.switchboard so existing installs keep working.
// Resolve env var at require-time (any later mutation would be ignored).
const DATA_DIR = process.env.SWITCHBOARD_DATA_DIR
? path.resolve(process.env.SWITCHBOARD_DATA_DIR.replace(/^~(?=$|\/)/, os.homedir()))
: path.join(os.homedir(), '.switchboard');
Expand All @@ -14,8 +15,9 @@ if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });

const DB_PATH = path.join(DATA_DIR, 'switchboard.db');

// Migrate from old locations if needed
const OLD_LOCATIONS = [
// Migrate from old locations if needed — never when running against an
// override dir, so a dev instance can't relocate the real app's legacy DB.
const OLD_LOCATIONS = process.env.SWITCHBOARD_DATA_DIR ? [] : [
path.join(os.homedir(), '.claude', 'browser', 'switchboard.db'),
path.join(os.homedir(), '.claude', 'browser', 'session-browser.db'),
path.join(os.homedir(), '.claude', 'session-browser.db'),
Expand Down Expand Up @@ -68,7 +70,8 @@ db.exec(`
parentSessionId TEXT,
agentId TEXT,
subagentType TEXT,
description TEXT
description TEXT,
fileMtime TEXT
)
`);

Expand Down Expand Up @@ -180,6 +183,20 @@ const migrations = [
try { db.exec('VACUUM'); } catch {}
searchFtsRecreated = true;
},
// v7: Split display time from cache invalidation. `modified` now holds the last
// message timestamp from the JSONL (resuming a session appends untimestamped
// bookkeeping records that bump mtime, making idle sessions show "just now");
// the new fileMtime column takes over as the re-index change-detection key.
// Clear cache so a re-index repopulates both columns.
//
// Upstream numbered this v4; here it lands as v7 because migrations are
// index-addressed (db_version == migrations.length) and our v4-v6 already
// shipped. Installs sitting at db_version 6 run exactly this one on upgrade.
(db) => {
try { db.exec('ALTER TABLE session_cache ADD COLUMN fileMtime TEXT'); } catch {}
try { db.exec('DELETE FROM session_cache'); } catch {}
try { db.exec('DELETE FROM cache_meta'); } catch {}
},
];

const currentDbVersion = (() => {
Expand Down Expand Up @@ -257,24 +274,31 @@ const stmts = {
cacheCount: db.prepare('SELECT COUNT(*) as cnt FROM session_cache'),
cacheGetAll: db.prepare('SELECT * FROM session_cache'),
cacheUpsert: db.prepare(`
INSERT INTO session_cache (sessionId, folder, projectPath, summary, firstPrompt, created, modified, messageCount, slug, aiTitle, parentSessionId, agentId, subagentType, description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO session_cache (sessionId, folder, projectPath, summary, firstPrompt, created, modified, messageCount, slug, aiTitle, parentSessionId, agentId, subagentType, description, fileMtime)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(sessionId) DO UPDATE SET
folder = excluded.folder, projectPath = excluded.projectPath,
summary = excluded.summary, firstPrompt = excluded.firstPrompt,
created = excluded.created, modified = excluded.modified,
messageCount = excluded.messageCount, slug = excluded.slug,
aiTitle = excluded.aiTitle,
aiTitle = excluded.aiTitle, fileMtime = excluded.fileMtime,
parentSessionId = excluded.parentSessionId, agentId = excluded.agentId,
subagentType = excluded.subagentType, description = excluded.description
`),
cacheGetByParent: db.prepare('SELECT * FROM session_cache WHERE parentSessionId = ? ORDER BY created ASC'),
// Kept as SELECT * (upstream narrowed this to sessionId+fileMtime): our
// reconcile path in session-cache.js caches the whole row so a header-only
// refresh can merge display fields without re-reading the transcript body.
// fileMtime still comes through, so upstream's invalidation key works.
cacheGetByFolder: db.prepare('SELECT * FROM session_cache WHERE folder = ?'),
cacheGetFolder: db.prepare('SELECT folder FROM session_cache WHERE sessionId = ?'),
cacheGetSession: db.prepare('SELECT * FROM session_cache WHERE sessionId = ?'),
cacheDeleteSession: db.prepare('DELETE FROM session_cache WHERE sessionId = ?'),
cacheDeleteFolder: db.prepare('DELETE FROM session_cache WHERE folder = ?'),
cacheTouchModified: db.prepare('UPDATE session_cache SET modified = ? WHERE sessionId = ?'),
// Bumps both columns: `modified` drives sidebar sort order, `fileMtime` is the
// re-index invalidation key (upstream's v7 split). Leaving fileMtime behind
// would make the touched row look dirty forever.
cacheTouchModified: db.prepare('UPDATE session_cache SET modified = ?, fileMtime = ? WHERE sessionId = ?'),
// Session metrics statements (per-(session,date,model) token/tool/message counts)
metricsDeleteBySession: db.prepare('DELETE FROM session_metrics WHERE sessionId = ?'),
metricsDeleteByFolder: db.prepare('DELETE FROM session_metrics WHERE sessionId IN (SELECT sessionId FROM session_cache WHERE folder = ?)'),
Expand Down Expand Up @@ -384,7 +408,8 @@ const upsertCachedSessionsBatch = db.transaction((sessions) => {
s.firstPrompt, s.created, s.modified, s.messageCount || 0,
s.slug || null, s.aiTitle || null,
s.parentSessionId || null, s.agentId || null,
s.subagentType || null, s.description || null
s.subagentType || null, s.description || null,
s.fileMtime || null
);
}
});
Expand Down Expand Up @@ -693,7 +718,7 @@ function closeDb() {
module.exports = {
getMeta, getAllMeta, setName, toggleStar, setArchived,
isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions,
touchCachedModified: (sessionId, modified) => stmts.cacheTouchModified.run(modified, sessionId),
touchCachedModified: (sessionId, modified, fileMtime = modified) => stmts.cacheTouchModified.run(modified, fileMtime, sessionId),
deleteCachedSession, deleteCachedFolder,
replaceSessionMetrics,
getFolderMeta, getAllFolderMeta, setFolderMeta,
Expand Down
Loading
Loading