Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 12 additions & 1 deletion main.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, screen, shell } = require('electron');
const { Worker } = require('worker_threads');

Check warning on line 2 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'Worker' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 2 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'Worker' is assigned a value but never used. Allowed unused vars must match /^_/u
const { execFile } = require('child_process');
const path = require('path');
const fs = require('fs');
Expand All @@ -16,7 +16,7 @@
}

// getFolderIndexMtimeMs moved to session-cache.js
const { startMcpServer, shutdownMcpServer, shutdownAll: shutdownAllMcp, resolvePendingDiff, rekeyMcpServer, cleanStaleLockFiles } = require('./mcp-bridge');

Check warning on line 19 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'cleanStaleLockFiles' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 19 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'cleanStaleLockFiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { fetchAndTransformUsage } = require('./claude-auth');
log.transports.file.level = app.isPackaged ? 'info' : 'debug';
log.transports.console.level = app.isPackaged ? 'info' : 'debug';
Expand All @@ -36,7 +36,7 @@
);

// Shell profiles → shell-profiles.js
const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs } = require('./shell-profiles');

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');

Expand Down Expand Up @@ -73,7 +73,7 @@
getMeta, getAllMeta, toggleStar, setName, setArchived,
isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions,
deleteCachedSession, deleteCachedFolder,
getFolderMeta, getAllFolderMeta, setFolderMeta,

Check warning on line 76 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'getFolderMeta' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 76 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'getFolderMeta' is assigned a value but never used. Allowed unused vars must match /^_/u
upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType,
searchByType, isSearchIndexPopulated, searchFtsRecreated,
getSetting, setSetting, deleteSetting,
Expand Down Expand Up @@ -289,8 +289,8 @@
setFolderMeta, getAllFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, populateCacheFromFilesystem,

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'populateCacheFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'populateCacheFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u
buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker } = sessionCache;

Check warning on line 293 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 293 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file');


Expand Down Expand Up @@ -1383,7 +1383,7 @@
// WSL profiles only work for plain terminals — Claude CLI sessions need the
// Windows shell because session data lives on the Windows filesystem.
const requestedProfile = resolveShell(effectiveProfileId);
const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal;

Check warning on line 1386 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 1386 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal)
? resolveShell('auto')
: requestedProfile;
Expand Down Expand Up @@ -1912,7 +1912,18 @@
log.error('[trigger-watcher] Failed to start trigger watcher:', err.message);
}

// Re-index search if FTS table was recreated (e.g. tokenizer config change)
// Full cache rebuild on every startup — prunes stale rows for deleted
// transcripts (sub-agent/workflow runs cleaned up between sessions leave
// ghost rows in session_cache that show in the sidebar but are
// inaccessible on open). populateCacheViaWorker runs in a Worker thread
// and is non-blocking; concurrent callers share the same in-flight
// Promise so the FTS-recreated path below (if also triggered) is free.
populateCacheViaWorker();

// Re-index search if FTS table was recreated (e.g. tokenizer config change).
// populateCacheViaWorker is already running above; the guard inside it
// (populatePromise !== null) means this is a no-op on the same tick and
// returns the shared Promise — no double scan.
if (searchFtsRecreated) populateCacheViaWorker();

// Check for updates after launch
Expand Down
31 changes: 19 additions & 12 deletions test/trigger-watcher.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
// No mocks — ctx provides a concrete in-memory PTY stand-in.
'use strict';

// Keep the discrete-Enter submit delay tiny so the suite stays fast and the
// turn-completion timing in makeChainCtx is not perturbed by a 50ms wait.
process.env.SWITCHBOARD_SUBMIT_ENTER_DELAY_MS = '1';

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
Expand Down Expand Up @@ -129,7 +133,7 @@ test('happy path: trigger → pty.write called, result ok:true, trigger deleted'
assert.equal(typeof result.waited_ms, 'number', 'waited_ms should be a number');

// pty.write called with command + \r
assert.deepEqual(ctx._written, ['/compact\r'], 'pty.write called with command + \\r');
assert.deepEqual(ctx._written, ['/compact', '\r'], 'pty.write: command text then discrete Enter');

// Trigger file deleted
assert.equal(fs.existsSync(triggerPath), false, 'trigger file should be deleted');
Expand Down Expand Up @@ -272,7 +276,7 @@ test('wait:idle while busy → flips to idle after 150ms → write happens, wait
result.waited_ms >= 100,
`waited_ms (${result.waited_ms}) should be >= 100ms`,
);
assert.deepEqual(ctx._written, ['/compact\r'], 'PTY write should happen after idle');
assert.deepEqual(ctx._written, ['/compact', '\r'], 'PTY write should happen after idle');

watcher.close();
} finally {
Expand Down Expand Up @@ -522,7 +526,7 @@ test('W4 concurrency cap: 12 simultaneous triggers all get processed', async ()
}

// 12 PTY writes should have happened
assert.equal(ctx._written.length, COUNT, `expected ${COUNT} PTY writes`);
assert.equal(ctx._written.filter((w) => w === '\r').length, COUNT, `expected ${COUNT} submitted commands`);

watcher.close();
} finally {
Expand Down Expand Up @@ -624,7 +628,7 @@ test('inFlight dedup: same filename event fired twice → processed at most once
assert.equal(result.ok, true);
// The trigger file is deleted after first processing, so any second fs.watch
// event for the same name finds no file and is silently skipped.
assert.equal(ctx._written.length, 1, 'pty.write called exactly once');
assert.equal(ctx._written.filter((w) => w === '\r').length, 1, 'command submitted exactly once');

watcher.close();
} finally {
Expand Down Expand Up @@ -704,7 +708,7 @@ test('W6 timeout_ms: per-trigger timeout_ms honored, overrides env-var fallback'
const result = readResult(path.join(tmp, 'processed'), uuid);
assert.equal(result.ok, true, 'result should be ok when timeout_ms overrides short env var');
assert.ok(result.waited_ms >= 100, `waited_ms (${result.waited_ms}) should be >= 100ms`);
assert.deepEqual(ctx._written, ['/compact\r'], 'PTY write should happen');
assert.deepEqual(ctx._written, ['/compact', '\r'], 'PTY write should happen');

watcher.close();
} finally {
Expand Down Expand Up @@ -986,7 +990,7 @@ test('W7 default helper: real-pid mock passes default signal-0 probe → happy p

const result = readResult(path.join(tmp, 'processed'), uuid);
assert.equal(result.ok, true, 'live pid → default helper returns true → ok');
assert.deepEqual(ctx._written, ['/help\r']);
assert.deepEqual(ctx._written, ['/help', '\r']);

watcher.close();
} finally {
Expand Down Expand Up @@ -1015,8 +1019,9 @@ function makeChainCtx(sessionId, opts = {}) {
write(data) {
if (opts.ptyThrows) throw new Error('PTY closed');
written.push(data);
// Auto-simulate a turn: become busy after 50ms, then idle after 200ms
if (!opts.noAutoTurn) {
// A turn only starts on submit (the discrete Enter), not when the command
// text lands. Auto-simulate: busy after 50ms, then idle after 200ms.
if (!opts.noAutoTurn && data === '\r') {
setTimeout(() => { busy = true; }, 50);
setTimeout(() => { busy = false; }, 200);
}
Expand Down Expand Up @@ -1087,7 +1092,7 @@ test('chain happy path: 3-step chain → 3 PTY writes, result ok:true with steps
assert.equal(typeof result.total_waited_ms, 'number');

// All 3 writes happened in order
assert.deepEqual(ctx._written, ['/compact\r', 'verify result file and commit\r', 'open the PR\r']);
assert.deepEqual(ctx._written, ['/compact', '\r', 'verify result file and commit', '\r', 'open the PR', '\r']);

watcher.close();
} finally {
Expand Down Expand Up @@ -1354,8 +1359,10 @@ test('chain timeout mid-chain: global timeout fires → ok:false, partial:true,
assert.match(result.error, /timeout/i, 'error should mention timeout');
assert.equal(result.steps_completed, 1, 'steps_completed should be 1 (step 0 done, step 1 failed)');

assert.equal(ctx._written[0], '/compact\r', 'step 0 should be written');
assert.equal(ctx._written[1], 'step-two\r', 'step 1 should be written (it was sent, just stuck)');
assert.equal(ctx._written[0], '/compact', 'step 0 text should be written');
assert.equal(ctx._written[1], '\r', 'step 0 Enter should be written');
assert.equal(ctx._written[2], 'step-two', 'step 1 should be written (it was sent, just stuck)');
assert.equal(ctx._written[3], '\r', 'step 1 Enter should be written');

watcher.close();
} finally {
Expand Down Expand Up @@ -1573,7 +1580,7 @@ test('chain instant-reply mid-chain: step 1 never sets busy → proceeds to step
const result = readResult(path.join(tmp, 'processed'), uuid);
assert.equal(result.ok, true, 'chain should succeed via instant-reply path');
assert.equal(result.steps.length, 3, 'all 3 steps must have run');
assert.deepEqual(ctx._written, ['/first\r', '/second\r', '/third\r']);
assert.deepEqual(ctx._written, ['/first', '\r', '/second', '\r', '/third', '\r']);
// Step 1's instant-reply path should have spent ~2s (BUSY_RISE_TIMEOUT_MS)
assert.ok(result.steps[1].waited_ms >= 1900 && result.steps[1].waited_ms <= 2400,
`step 1 should have waited ~2000ms for the rising edge; got ${result.steps[1].waited_ms}ms`);
Expand Down
30 changes: 27 additions & 3 deletions trigger-watcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ const MAX_CHAIN_LENGTH = 20; // max steps per chain (W8)
// Claude may answer so fast that we never observe the rising edge;
// after BUSY_RISE_TIMEOUT_MS we assume the turn completed instantly and move on.
const BUSY_RISE_TIMEOUT_MS = 2000; // ms
// Delay (ms) between writing a command's text and the Enter keypress that
// submits it. The Enter MUST arrive as a discrete PTY read — concatenated onto
// the text in a single write, Claude Code (kitty keyboard protocol) absorbs it
// as a literal newline in the composer and the command never submits. xterm.js
// sends every keypress as its own write, which is why the web terminal submits
// correctly; we mirror that. Reproduced 2026-06-02: free-text trigger commands
// landed in the composer but did not submit; only the short menu-driven
// /compact path submitted. Override via SWITCHBOARD_SUBMIT_ENTER_DELAY_MS.
const DEFAULT_SUBMIT_ENTER_DELAY_MS = 50; // ms
// Control chars forbidden in command: CR, LF, NUL, ESC (W3)
const FORBIDDEN_COMMAND_RE = /[\r\n\0\x1b]/;

Expand All @@ -65,6 +74,21 @@ function defaultIsPtyAlive(ptyProcess) {
}
}

function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

// Submit a command to a PTY the way a human terminal does: write the text,
// then send Enter as a SEPARATE write so it is read as a discrete "submit"
// keypress rather than a trailing newline. See DEFAULT_SUBMIT_ENTER_DELAY_MS.
async function submitToPty(ptyProcess, command) {
ptyProcess.write(command);
const envMs = Number(process.env.SWITCHBOARD_SUBMIT_ENTER_DELAY_MS);
const ms = Number.isFinite(envMs) && envMs >= 0 ? envMs : DEFAULT_SUBMIT_ENTER_DELAY_MS;
await delay(ms);
ptyProcess.write('\r');
}

function getTriggersDir() {
return process.env.SWITCHBOARD_TRIGGERS_DIR || DEFAULT_TRIGGERS_DIR;
}
Expand Down Expand Up @@ -445,9 +469,9 @@ async function processTriggerFile(name, ctx, triggersDir, processedDir) {
return;
}

// Write to PTY
// Write to PTY: text, then Enter as a discrete keypress (see submitToPty).
try {
ptyProcess.write(command + '\r');
await submitToPty(ptyProcess, command);
} catch (err) {
ctx.log.error('[trigger-watcher] PTY write failed:', err.message);
await writeResult({ ok: false, error: 'pty write failed: ' + err.message, sessionId });
Expand Down Expand Up @@ -528,7 +552,7 @@ async function processTriggerFile(name, ctx, triggersDir, processedDir) {
if (i === 0) step0SentAt = stepSentAt;

try {
entry.ptyProcess.write(step.command + '\r');
await submitToPty(entry.ptyProcess, step.command);
} catch (err) {
ctx.log.error(`[trigger-watcher] PTY write failed at chain step ${i}:`, err.message);
await writeResult({ ok: false, error: 'pty write failed: ' + err.message, partial: true, steps_completed: i, sessionId, sent_at: step0SentAt, steps, total_waited_ms: totalWaitedMs });
Expand Down
Loading