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
73 changes: 73 additions & 0 deletions .ai/contexts/trigger-watcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,79 @@ still applies once a rise is observed (unchanged by the rise-wait bound)`
is true). The two pre-existing settle-window spec tests above — including
the documented deadline-vs-settle trade-off — remain green unmodified.

### Readiness and edge proof from the CLI descriptor (issue #407)

Field case (2026-10-01): after a `/compact` chain step, the next step's text
landed in the composer but its Enter became a line break, while the log said
`Chain step 1 sent`. Two defects stacked. The level probe
(`pollForBusyObserved`) accepts any `_cliBusy = true` within the window, and
`_cliBusy` comes from OSC titles / OSC 9;4, so the spinner that ends a
compaction satisfied it and the recovery Enter was never armed. And nothing
waited for the CLI to be back at its prompt: #185 (settle), #190 (rise wait)
and the `midBusy` gate all read the same terminal-derived signal, which is
not the CLI's own account of its state (the 0.0.64 incident above: even a
lone retry `\r` seconds later was absorbed).

The CLI's own descriptor (`~/.claude/sessions/<pid>.json`, read by
`cli-session-state.js`, see `cli-session-state.md`) is the better source:
`status` is `idle` at the prompt, `waiting` when a dialog is open, `busy`
while working, and `statusUpdatedAt` is written on change. The watcher reaches
it through the optional `ctx.getCliStatus(sessionId)` (`trigger-context.js`,
wired to `cliSessionState.getStatus` in `main.js`; `undefined` for a remote
session, which has no local descriptor). No new watcher: it reuses the cache
`cli-session-state.js` already keeps.

- **Readiness.** A chain step that follows a `/compact` step first waits
(`waitForCliIdleAfter`) for `status: "idle"` with a `statusUpdatedAt` later
than the compact step's send time. Bounded by
`SWITCHBOARD_CLI_READY_WAIT_MS` (default 60 000 ms) and by the step's own
deadline; on expiry the step is written anyway, with the warning `CLI not
idle after /compact within N ms, writing chain step N anyway`. Its time is
counted in the step's and the chain's `waited_ms`.
- **Proof of submission by edge.** When a descriptor with an integer
`statusUpdatedAt` is available, a submission counts when the descriptor
shows ANY status write (`busy`, `idle` or `waiting`) with a
`statusUpdatedAt` at or after the moment of our Enter (`cliReactedSince`):
the CLI reacted. `idle` alone covers a turn too fast for a poll to see
`busy`; `waiting` is a permission dialog our Enter opened. A spinner on the
level probe, or a status that began earlier, proves nothing. Otherwise the
existing recovery applies (one bare ``, only into a free composer, same
window), and the reaction is looked for again. Still nothing:
`confirmed: false`.
- **The recovery Enter is never written while the descriptor reads `waiting`
or `busy`** (`cliForbidsRecoveryEnter`, in edge and fallback modes, whenever
the descriptor has a status, even without a usable timestamp): a bare Enter
would answer the dialog with its default, or land in a running turn. The
step reports `recoverySkipped` and `confirmed: false`.
- **A descriptor whose `statusUpdatedAt` is not an integer** is treated as no
descriptor for readiness and proof (old behaviour), not as one that never
matches.
- **Result and log.** `submitWithVerify` returns `confirmed`: `true` (edge
seen), `false` (descriptor available, no edge even after the recovery Enter)
or `null` (no descriptor: the level probe decides, as before). `true` logs
`Chain step N submitted to ...` (single command: `Submitted command`);
`false` logs the warning `Chain step N not confirmed submitted to ...`
(`Command not confirmed submitted`) and never `sent`; `null` keeps `sent`.
A confirmed step reads `submitted: "confirmed"`; an unconfirmed one reads
`"assumed"`, so the chain fold drops to `assumed` too. The step carries
`submit_confirmed` and the chain result lists `unconfirmed_steps` (indexes)
when there are any. Both fields are absent without a descriptor.
- **What it does not cover.** A command that never makes the CLI busy (a
local slash command that answers at once) cannot show a busy edge: it takes
the recovery `\r` (a no-op on an empty composer) and is reported
unconfirmed. The descriptor is written by the CLI process; the edge is as
fresh as `cli-session-state.js`'s watch of that file.

Unverified against a real CLI: whether waiting for the descriptor's idle
actually makes the Enter after `/compact` submit. The deciding measurement
(isolated instance, real CLI) is `/compact` then text with
`SWITCHBOARD_SUBMIT_ENTER_DELAY_MS` 50 / 500 / 3000, with and without the
readiness wait. Until then the fix guarantees the failure is recovered once or
named, not that the first Enter always lands.

Tests: `test/trigger-descriptor-proof.test.js` (fake timers and a fake
descriptor for the helpers; the real watcher for the chain wiring).

### Why `composerEmptyAfterWrite` cannot be made to prove submission, even by feeding it our own writes

A proposal, considered and rejected 2026-09-04: since `submitToPty` writes
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc
## Unreleased

### Fixed
- A step of a trigger chain that follows `/compact` now waits for the CLI to be back at its prompt before it is written, and a step whose Enter did not start a turn is retried once and then reported as "not confirmed submitted" in the log and the result instead of "sent". (#407)
- Stopping a terminal twice in quick succession, or resizing it while it is being stopped, no longer closes the Windows pseudo console twice, which could kill the whole app with no error. (#405)
- A sandboxed session, or a sandboxed schedule, whose Additional Directories include a `.claude` or `.git` directory, or a path inside one, is now refused instead of binding it read-write over its read-only protection; add the project directory instead. A session started in a `.claude` or `.git` directory is refused too, except below `.claude/worktrees`, and Additional Directories naming your home directory or a parent of it are refused however the path is written. A relative `add-dirs` entry in a schedule is taken from the schedule's directory. (#385)
- A session that has exited no longer keeps a busy dot in the sidebar, and the status bar's running count drops as soon as the session ends instead of waiting for the next refresh. (#375)
Expand Down
1 change: 1 addition & 0 deletions docs/automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ spends the same budget.
| `SWITCHBOARD_TRIGGER_MAX_AGE_MS` | The staleness limit | 300 000 |
| `SWITCHBOARD_SUBMIT_ENTER_DELAY_MS` | Delay between the text and its Enter | 50 |
| `SWITCHBOARD_SUBMIT_VERIFY_MS` | How long a submission is watched for a turn | 2 000 |
| `SWITCHBOARD_CLI_READY_WAIT_MS` | How long a chain step after `/compact` waits for the CLI to report idle | 60 000 |
| `SWITCHBOARD_BUSY_FALL_SETTLE_MS` | How long "not busy" must hold between chain steps | 300 |

The triggers directory does not move with `SWITCHBOARD_DATA_DIR`: an instance
Expand Down
2 changes: 1 addition & 1 deletion main.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
}

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

Check warning on line 71 in main.js

View workflow job for this annotation

GitHub Actions / lint

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

Check warning on line 71 in main.js

View workflow job for this annotation

GitHub Actions / lint

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler, scheduleBindRefusals, resolveScheduleSandbox, scheduleRegistry } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { SETTING_DEFAULTS } = require('./public/setting-defaults');
Expand Down Expand Up @@ -478,8 +478,8 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 481 in main.js

View workflow job for this annotation

GitHub Actions / lint

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

Check warning on line 481 in main.js

View workflow job for this annotation

GitHub Actions / lint

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

Check warning on line 482 in main.js

View workflow job for this annotation

GitHub Actions / lint

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

Expand Down Expand Up @@ -2446,7 +2446,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 2449 in main.js

View workflow job for this annotation

GitHub Actions / lint

'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 @@ -3183,7 +3183,7 @@
// 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(createTriggerContext({ activeSessions, log }));
require('./trigger-watcher').start(createTriggerContext({ activeSessions, log, getCliStatus: (id) => cliSessionState.getStatus(id) }));
} catch (err) {
log.error('[trigger-watcher] Failed to start trigger watcher:', err.message);
}
Expand Down
18 changes: 18 additions & 0 deletions test/trigger-context.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,21 @@ test('log is forwarded, and isPtyAlive is only present when supplied', () => {
});
assert.equal(withProbe.isPtyAlive, probe);
});

test('getCliStatus is only present when supplied, and answers for local live sessions only', () => {
assert.equal('getCliStatus' in createTriggerContext({ activeSessions: new Map(), log: silentLog }), false);

const sessions = new Map([
['local', { pty: {}, host: null }],
['remote', { pty: {}, host: 'box', handle: {} }],
]);
const seen = [];
const ctx = createTriggerContext({
activeSessions: sessions, log: silentLog,
getCliStatus: (id) => { seen.push(id); return { status: 'idle', statusUpdatedAt: 5 }; },
});
assert.deepEqual(ctx.getCliStatus('local'), { status: 'idle', statusUpdatedAt: 5 });
assert.equal(ctx.getCliStatus('remote'), undefined, 'a remote session has no local descriptor');
assert.equal(ctx.getCliStatus('unknown'), undefined);
assert.deepEqual(seen, ['local']);
});
Loading
Loading