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
1 change: 1 addition & 0 deletions .ai/contexts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Five sub-system docs, ~150 lines each, written for AI agents who need to make a
| Subagent sidebar grouping, transcript view, parent→child wiring | [subagent-observability](subagent-observability.md) |
| Plans/Memory/.work-files tabs, CodeMirror panel, format/delete buttons | [viewer-panel](viewer-panel.md) |
| New IPC, preload bridge changes, renderer ↔ main protocol | [ipc-bridge](ipc-bridge.md) |
| File-trigger watcher, harness input injection, idle-wait | [trigger-watcher](trigger-watcher.md) |

## Reading order for a new contributor (~30 min)

Expand Down
76 changes: 76 additions & 0 deletions .ai/contexts/trigger-watcher.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Context: trigger-watcher

**Purpose**: lets external harness scripts inject keyboard input into open PTY sessions without any Electron IPC. The primary use case is the token-usage harness calling `/compact` on a session when it detects a high context-window fill.

## Key files

| File | LOC | Role |
|---|---|---|
| `trigger-watcher.js` | ~175 | The entire module: directory setup, `fs.watch` listener, idle-wait logic, PTY write, result file. |
| `main.js` (wiring) | 15 | `require('./trigger-watcher').start(ctx)` in the `app.whenReady` block, right after `startScheduler`. |

## Public surface

### `start(ctx)` → `{ close() }`

Starts the watcher. Call once at app boot.

```js
require('./trigger-watcher').start({
log, // electron-log compatible
getPtyForSession(sessionId), // → { ptyProcess } | null
isSessionBusy(sessionId), // → boolean
});
```

`getPtyForSession` returns `null` when the session is unknown or has already exited.
`isSessionBusy` reads `session._cliBusy` — the same flag that tracks OSC 0 title-change spinner chars.

The returned `{ close() }` handle is not held by `main.js` (no graceful-close needed — Electron kills the process, and `persistent: false` is not set so the watcher keeps the event loop alive naturally).

## Trigger file contract

Drop a file at `SWITCHBOARD_TRIGGERS_DIR/<uuid>.json` (default `~/.switchboard/triggers/`):

```json
{
"sessionId": "abc-123-def",
"command": "/compact",
"wait": "idle"
}
```

Fields:
- `sessionId` — must match a key in `activeSessions` (`main.js`)
- `command` — written verbatim as `command + '\r'` to the PTY
- `wait` — `"none"` (default) | `"idle"`. `"idle"` polls `isSessionBusy` every 100 ms, up to `SWITCHBOARD_TRIGGER_IDLE_TIMEOUT_MS` (default 30 000 ms).

Result written to `SWITCHBOARD_TRIGGERS_DIR/processed/<uuid>.result.json`:

```json
{ "ok": true, "sessionId": "...", "command": "...", "sent_at": "...", "waited_ms": 320 }
{ "ok": false, "error": "<reason>", "sessionId": "..." }
```

Trigger file is **deleted** after processing (success or failure).

## Invariants

- **Never throws out of the watcher callback** — every error path lands in the result file.
- **Deduplication via `inFlight` Set** — noisy `rename` events for the same file (common on Linux inotify) are coalesced; a file is processed at most once per appearance.
- **`accessSync` guard** — the `rename` event fires both on file creation and deletion; the existence check prevents processing a deletion event.
- **Directories ignored** — non-`*.json` filenames and any name containing `/` or `path.sep` are skipped.

## Non-obvious behaviors

- **OSC-title-based busy detection**: `session._cliBusy` is set to `true` by the OSC 0 handler in `main.js` when the title starts with a Braille spinner char (U+2800–U+28FF), and back to `false` when the ✳ idle char (U+2733) appears. The trigger watcher reuses this flag directly via `isSessionBusy`.
- **`wait:"none"` sends even if busy** — it is the caller's responsibility to choose the right wait strategy. The harness should use `"idle"` for `/compact` to avoid injecting into a mid-response stream.
- **Timeout env var for tests** — `SWITCHBOARD_TRIGGER_IDLE_TIMEOUT_MS` lets tests use a 200 ms timeout instead of 30 s, making the idle-timeout test fast.
- **`persistent: true` on `fs.watch`** — the watcher keeps the Node event loop alive, matching the pattern of all other persistent watchers in `main.js` (projects watcher, subagent watcher).

## Change-also checklist

- If you rename `_cliBusy` on `session` in `main.js`, update `isSessionBusy` in the `start(ctx)` wiring block.
- If you rename `activeSessions` or change the structure (`session.pty` → `session.ptyProcess`), update both `getPtyForSession` and `isSessionBusy` in the wiring block.
- Tests live in `test/trigger-watcher.test.js`. They use `SWITCHBOARD_TRIGGERS_DIR` env override — do not hardcode paths there.
- The convention doc for harness script authors lives at `~/.skaleet-ai/conventions/how-to/switchboard-trigger.md`.
21 changes: 21 additions & 0 deletions 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 @@ -1891,6 +1891,27 @@
scheduleIpc.init(log, runScheduleCommand);
startScheduler(log, runScheduleCommand);

// File-trigger watcher — allows harness scripts to inject input into open
// PTY sessions by dropping a JSON file in ~/.switchboard/triggers/.
// I3: wrapped in try/catch so a boot failure here doesn't abort
// app.whenReady (auto-updater, etc. would otherwise be silently lost).
try {
require('./trigger-watcher').start({
log,
getPtyForSession(sessionId) {
const session = activeSessions.get(sessionId);
if (!session || session.exited) return null;
return { ptyProcess: session.pty };
},
isSessionBusy(sessionId) {
const session = activeSessions.get(sessionId);
return session ? !!session._cliBusy : false;
},
});
} catch (err) {
log.error('[trigger-watcher] Failed to start trigger watcher:', err.message);
}

// Re-index search if FTS table was recreated (e.g. tokenizer config change)
if (searchFtsRecreated) populateCacheViaWorker();

Expand Down
Loading
Loading