Repository navigation
Description
Ready for implementation. The converged specification is in two comments: part 1 · 2. It supersedes the text below.
Goal
Another agent can reach a session that is closed. A trigger names the session by id and carries a prompt. Switchboard reopens the session as an ordinary visible session (sidebar entry, terminal, claude --resume <id>), waits until it is idle, then delivers the prompt. Sessions no longer need to stay open just to remain reachable. Today a session kept open only for that also receives the morning wake-up and the shutdown handshakes.
Never a background or headless run: no claude -p, no hidden PTY.
Today
- The trigger watcher only types into a session already open in
activeSessions(.ai/contexts/trigger-watcher.md,sessionIdfield). A trigger aimed at a closed session fails. - (triggers): let a session open sister sessions and follow their state #380 (spec converged, Phase 0 merged in (canary): Phase 0 session capability harness, evidence and ledger (#380) #705) adds an open-session trigger in two kinds: fresh, and side-quest fork. It resumes only a "saved sister", a session that trigger opened itself. It never resumes an arbitrary existing session.
What is needed
- A resume-and-deliver trigger. It is a file in the existing triggers directory, using the same format, results in
processed/and age limits. It is a kind added to (triggers): let a session open sister sessions and follow their state #380's open-session trigger, or one beside it.sessionId(required) andprompt(required).- Optional
cli, following (triggers): let a session open sister sessions and follow their state #380's 2026-10-28 amendment. - When the session is open here, the prompt goes through the existing delivery path.
- When it is closed, the session is resumed visibly, then the prompt is delivered once it is idle.
- The result reports
resumed: true|falseand the usualsubmittedvalue.
- Reuse (triggers): let a session open sister sessions and follow their state #380's guards.
- Admission and the
maxTriggeredSessionscap: a resumed session counts. - Known projects only, from the transcript's recorded cwd.
- Launch restrictions saved with the session are re-applied (D2): a resume never loosens them.
- A sandboxed session cannot write the triggers directory.
- Admission and the
- Resume guard. The cases in
public/resume-guard.jsand.ai/contexts/cli-session-state.md("Live elsewhere") are refused with a stated reason, never attached or forked:- live in another process;
- backgrounded;
- scheduled;
- no readable transcript.
Remote-host sessions are out of scope for v1.
- Concurrency. Two triggers for the same closed session resume it once; the second waits on the first, using the per-session FIFO lock already in the watcher.
Out of scope
Mapping a human-readable name to a session id. A caller resolves the name to an id before writing the trigger. In JBR's harness that is done from the resume notes' front matter, which carries the session name, Claude session id, cwd and on-demand flag. An unknown name is refused there. Switchboard takes only an id.
Open, for the spec
- Placement: a kind of (triggers): let a session open sister sessions and follow their state #380's trigger, or a separate file kind.
- Window focus on resume: steal focus or not.
- Behaviour when the cap is full: refuse, or queue until the trigger's age limit.
- Ordering against (triggers): let a session open sister sessions and follow their state #380's implementation: the spec says whether this waits on (triggers): let a session open sister sessions and follow their state #380 or carries its own admission path.
Activity
jbr-sekoia commentedon Nov 5, 2026
Part 1/2
Visible session lifecycle triggers — issue #1022
Implementation baseline:
e0f144b4048f7ddb8a9cb76b61001c94e599ceef.The approved the v1 scope (Appendix A) is authoritative for v1. This specification defines
the implementation; sections marked HEAD describe existing behavior.
1. Contract and boundary
A versioned file trigger opens a local session as an ordinary Switchboard
session and optionally delivers a prompt, creates a new local session with a
prompt, or stops an open local session after an optional final turn. A resumed
session has the normal sidebar row, terminal entry, PTY, output routing and
working-set persistence. Its CLI command uses
claude --resume <id>.The caller supplies session IDs and project paths. Name resolution belongs to
the caller. There is no name lookup, external terminal, headless invocation,
automation-only terminal, background attachment, remote operation, fork,
per-trigger launch option, admission setting, or maximum-open-session setting.
This implementation does not depend on #380. In particular, it does not add
maxTriggeredSessions, saved sister sessions, or historical launch-policysnapshots. Resume uses the same current saved project defaults as a sidebar
resume; new uses the defaults of the project's ordinary + → Claude action.
The effective triggers directory is a host control surface. Without an exposed
Podman socket, sandboxed sessions must not be able to write, rename, replace or
publish files in it, including
processed/results and action markers. Thisapplies to every sandbox launch, including manual sessions and schedules, when
a project, worktree, extra directory or writable state mount would otherwise
expose it. When
SWITCHBOARD_SANDBOX_PODMAN=1exposes the Podman socket, thisprotection does not hold: the service can mount host paths writable in another
container, bypassing the sandbox's read-only mounts, as documented in
docs/sandbox.md:404–407. The existing Podman opt-in remains supported; socketexposure does not itself refuse a launch. The filesystem boundary, this limit
and fail-closed handling of unsafe mount layouts are specified in §6.
Window policy: do not raise, restore, show, focus, or flash the native window,
and do not change the selected session, active view, modal, or keyboard focus.
The new/resumed session is a normal selectable open session in the sidebar;
in grid view it gets the ordinary card without focusing it. An inactive normal
tab is permitted; a separate hidden PTY is not. This policy also applies when
the app is already foregrounded. There is no
focusfield in v1.2. HEAD inventory and implementation consequences
Every reference below is relative to the baseline above.
main.js:4013;src/main/triggers/trigger-context.js:40~/.switchboard/triggers, withSWITCHBOARD_TRIGGERS_DIRoverride. Timeout defaults to 300,000 ms; explicit timeout is a positive integer no greater than 600,000.src/main/triggers/trigger-watcher.js:43,:51,:934,:1023src/main/triggers/trigger-watcher.js:1108,:1126,:1133,:1152,:962sessionIdpluscommand,chainorkey. Missing local PTYs may use descriptor ownership fallback or the enabled remote socket branch; otherwise the result issession not found.src/main/triggers/trigger-watcher.js:1182,:1330,:1352,:1375src/main/triggers/trigger-context.js:43,:62,:92src/main/triggers/trigger-watcher.js:1190,:1392,:2065;.ai/contexts/trigger-watcher.md:201src/main/triggers/trigger-context.js:111,:127,:210,:247,:324processed/<basename>.result.json, write a temporary file, rename with retry, and unlink the trigger. Result defaults includesubmitted,wrote,steps_total. Publication failure currently still deletes the input.src/main/triggers/trigger-watcher.js:1059,:1064,:1079,:1085;.ai/contexts/trigger-watcher.md:2091fs.watch, then scans existing root JSON files through the same dispatch queue. Eight files may be in flight; filename deduplication and retained filenames are in memory.src/main/triggers/trigger-watcher.js:57,:2055,:2093,:2111,:2138,:2160submitWithVerify, and wait for busy fall only for non-final steps. Ano_risecompletion is possible.src/main/triggers/trigger-watcher.js:1664,:1853,:1965,:882,:926stop.beforeneeds an internal final-turn wait with positive evidence, not an ordinary one-command chain or a dummy second command.openSessiondeduplicates renderer openings.openSessionNowcan show an existing entry, resolve continuations, restore/attach background jobs, run the resume guard, create a terminal and invokeopen-terminal. Successful opens can returnundefined.public/app.js:2465,:2493,:2516,:2522,:2535,:2606,:2617,:2630,:2685undefinedas successful opening.automatic:trueandallowBgAttach:false; an interactive click can confirm duplication or attach a background job. Lookup exceptions can currently become no live session.public/resume-guard.js:25,:46,:54,:59,:70public/session-activity-dom.js:119,:127;public/resume-guard.js:115,:157;public/sidebar.js:745launchNewSessionallocates an ID, adds a pending sidebar row and terminal, callsopen-terminalwithisNew:true, persists and returns the ID or null.public/app.js:2320,:2336,:2353,:2362,:2376launchNewSession. Defaults include permissions, worktree, sandbox on Linux, Chrome, pre-launch command, added directories and MCP setting. Project settings override global settings, including explicit null.public/dialogs.js:7,:97;main.js:1776resolveDefaultSessionOptions; do not add prompt-derived CLI arguments or partially reconstruct settings.scripts/claude-sandbox.sh:848;docs/sandbox.md:411;cli-session-state.js:520,:889,:908;main.js:4014.claudeand.git. The effective triggers directory is configurable and has no corresponding protection.main.js:3163,:3949;scripts/claude-sandbox.sh:193,:645;src/main/triggers/trigger-watcher.js:934open-terminalreattaches only by its exact key.src/main/triggers/trigger-context.js:62,:92;session-transitions.js:631;main.js:2833,:2871submitWithVerifycan returntimedOut:trueafter Enter; the ordinary single-command caller does not map that flag to failure.src/main/triggers/trigger-watcher.js:774,:1608,:1645archived-projects.js:68,:113;session-cache.js:1076;public/sidebar.js:1304,:1740;public/dialogs.js:97open-terminalreattaches an existing entry, can attach a remote session, resolves verified recorded cwd for resume, and falls back to starting fresh if no transcript exists. Worktree creation only applies to fresh starts.main.js:2828,:2833,:2874,:2902,:3044,:3054,:3069resolveSessionRealCwduses verified transcript cwd;sessionTranscriptExistsonly checks existence. Cached rows can locate transcripts.derive-project-path.js:254,:271;session-index-store.js:626,:631;read-session-file.js:20cli-session-state.js:66,:799,:818,:1111,:1120;main.js:3559,:3587;public/resume-guard.js:53liveElsewhereV1Checkedas specified in §7, including schedule/background checks and namespace resolution without a local PTY. ExistingliveElsewhereCheckedalone cannot authorize v1 resume.stop-sessionsetsstopRequested, kills a normal PTY, or detaches an attach tab.public/app.js:1392;public/stop-session-ui.js:16,:297;main.js:1964session.exitedand sendsprocess-exitedwith the generation. Windows termination can be asynchronous.main.js:1974,:2700,:2718;src/main/terminals/pty-ops.js:108closedonly after the pinned entry exits; a kill request alone is insufficient.openSessions.showSessionchanges selected state and focuses the terminal.public/terminal-manager.js:1097,:1239,:1266,:1510,:1572focusGridCard.webContents.send, and preload exposes named callbacks. Unsaved-check requests correlate replies and validate their sender.src/main/integrations/mcp/mcp-bridge.js:242;preload.js:201,:210,:246;src/main/app/unsaved-guard.js:113,:121public/app.js:2935,:2950,:2973;main.js:450,:4068,:4074did-finish-load, and do not create a window for a trigger..ai/contexts/trigger-watcher.md:364;src/main/triggers/trigger-watcher.js:20403. Wire format
Publish UTF-8 JSON at
<triggersDir>/<trigger-uuid>.json, preferably by renaminga fully written file from the same directory whose temporary name does not end
in
.json. The filename stem must be a lowercase UUID. It identifies therequest, not the target session.
Results remain at
processed/<trigger-uuid>.result.json. Never reuse a v1request UUID. A duplicate request UUID denotes the same action, even if its
payload changes; its first terminal result wins.
The three allowed shapes are:
{"version":1,"action":"open","sessionId":"01234567-89ab-cdef-0123-456789abcdef","prompt":"Continue the task","timeout_ms":300000,"expectedCwd":"/work/project"}{"version":1,"action":"open","new":{"project":"/work/project","name":"Follow-up"},"prompt":"Investigate the failure","timeout_ms":300000}{"version":1,"action":"stop","sessionId":"01234567-89ab-cdef-0123-456789abcdef","before":"Save a resume note and finish this turn","timeout_ms":300000,"expectedCwd":"/work/project"}promptis optional only for resume. Omission means open/ensure an opensession without typing. An empty or null prompt is invalid, not omission.
beforeis optional; it is one prompt string, not an object or public chain.new.project,new.nameand new-sessionpromptare required.timeout_msis optional everywhere.
expectedCwdis optional only withsessionIdanduses the existing actual-cwd comparison, including worktree cwd rather than
the collapsed project root. No caller-supplied cwd is used to launch resume.
Validation order and exact reasons
The first failing check wins, in the following order. A failure has
outcome:"refused",ok:false,reasonas specified andsubmitted:"no"unless a later execution step actually wrote bytes. Keep dynamic OS/parser
messages in
error/detail, not in a new stable refusal code.reasontrigger must be an objectversionoractionexists, butversion !== 1(no coercion)unsupported versionopenorstopunsupported actiontrigger filename must be a lowercase UUIDsessionIdandnewopen requires exactly one of sessionId or newsessionIdmissing required field: sessionIdunsupported field: <path>sessionIdis not a string matching the UUID rulesessionId must be a UUIDnewis not a non-null, non-array objectnew must be an objectnew.projectis absent, not a string, empty, or not an absolute native pathnew.project must be an absolute pathnew.nameis absent, not a string, or whitespace-onlynew.name must be a non-empty stringnew.name.length > 256new.name too long (max 256 characters)new.namecontains U+0000–U+001F or U+007Fnew.name contains control characterspromptor presentbeforeis not a string, or is whitespace-only<field> must be a non-empty string<field> too long (max 4 KB)<field> contains forbidden control characters (\r \n \0 \x1b)invalid timeout_msexpectedCwdexpectedCwd must be a non-empty string when present<field>is literally replaced withpromptorbefore;<path>is thelexicographically first disallowed dotted field, for example
cli,wait,new.optionsornew.permissionMode. For nested object errors, check objecttype before enumerating its keys. Top-level allowed keys are exactly those
shown for that mode; a property with null/false still counts as present.
In particular, stop cannot carry
neworprompt; resume cannot carrybefore;new cannot carry
sessionIdorexpectedCwd. Rejectcommand,key,chain,cli,options,focusand arbitrary launch flags rather than ignoring them.UUID rule:
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.Accept uppercase session IDs and normalize them before lookup and locking.
This checks shape, not UUID version/variant. It excludes names,
sub:..., bridgeIDs, whitespace and paths. Request filenames use the same pattern without the
iflag, so their input/result/marker paths have one canonical spelling. Prompts are nottrimmed or shell-quoted before delivery. The 4096 limit deliberately matches
legacy command
.length, while the outer file limit remains 64 KiB of bytes.Trim the validated display name for storage; names need not be unique.
Neither
versionnoractionpresent means legacy dispatch, with its currentcommand/chain/key fields, permissive session identifiers, defaults, results,
remote behavior and timing. A lone
actionis never interpreted as legacy.Malformed JSON and pre-parse file errors cannot be reliably classified as v1:
retain HEAD's envelope and exact errors for those failures. Recheck staleness
after a v1 FIFO wait; that recognized v1 refusal uses
error:"not sent"andHEAD's complete dynamic
reasonatsrc/main/triggers/trigger-watcher.js:1145.Never parse an oversized file merely to identify its version.
4. Results
All requests classified into the versioned family, including unsupported
versions/actions, have a terminal result with this common envelope:
{"version":1,"action":"open","ok":true,"outcome":"opened","reason":null,"sessionId":"01234567-89ab-cdef-0123-456789abcdef","resumed":true,"submitted":"confirmed","wrote":true,"steps_total":1}version,actionoutcomeopened,closed,refused, ornot-found.okopenedorclosedafter the requested action, including required delivery, succeeds.reasonsessionIdresumedcreatedsubmitted,wroteno,assumed,activity,confirmed;wroteis delivery's write-attempt/partial-write evidence, not proof a turn completed. Opening or killing a PTY does not count as prompt writing.steps_totalsent_at, wait timings, retries, partial text/Enter evidence, confirmation, compaction and step evidence from the shared delivery function when applicable.error,detailreasonremains the stable lifecycle contract.uncertainOpen's success ends after delivery's normal submission verification completes
within the action deadline; it does not wait for the answer. A verification
result with
timedOut:trueis never an open success. If verification times outafter Enter, return
outcome:"refused",ok:false,reason:"session action timed out",uncertain:true, retaining the ID,resumed/created,wrote:true,text_written:true,enter_written:true,steps_total:1and all submission/verification evidence. With no observedreaction,
submittedremains"assumed", not"no"or"confirmed".Leave the session open, cancel further delivery work and never retry
automatically. Stop's corresponding note timeout retains §7's
reason:"note step not finished"and performs no stop.A delivery success completed within budget can still have
submitted:"assumed";do not upgrade it to confirmed. Open without prompt succeeds after the normal
terminal is acknowledged and its main entry is verified, with
submitted:"no",wrote:false,steps_total:0.If opening succeeds but readiness/delivery/name persistence fails, return
outcome:"refused", retain the ID and trueresumed/createdevidence, andleave the ordinary session open. There is no rollback kill. A caller must not
interpret
refusedas proof that no process or prompt exists. An uncertainresult likewise makes a false
resumed/createdmean “not confirmed,” not“definitely did not happen.” On restart, emit true flags only if the action
marker durably records that evidence.
closedmeans the pinned PTY exited, not merely that stop IPC returned. Atimeout after kill was requested is
refused,reason:"session stop not confirmed",uncertain:true; termination already initiated may still finish later. Thisdiffers from a note timeout, for which no kill is requested at all.
5. Resolution and behavior by case
These checks run under the session FIFO, before sending renderer work or text.
Use the existing direct/historical/descriptor-to-tab lookup to recognize an
already-open local target. Preserve its ambiguity and ownership refusals.
Otherwise use the strict closed-session resolver described below. Never route a
v1 request through the remote messaging adapter, even if remote triggers are enabled.
reasonon failureopened,resumed:false. No transcript requirement for this case, including an ordinary just-created session.reattachOnly:true; never spawn. Then deliver as above,resumed:false.resumed:trueonly after spawn.refused,session live elsewhere. No prompt, duplication confirmation, fork or attach.refused,background session is not supported. This includesbackgrounding,backgrounded,isAttachand a livekind:"bg"descriptor. Legacy attach-tab delivery is unchanged.refused,scheduled session is not supported. Check tracked schedule runs even without a readable descriptor. A completed historical schedule transcript may be resumed if no run remains live.refused,remote sessions are not supported. Apply regardless of remoteTriggers setting. Local proven target takes precedence over a remote ID collision; no proven local target plus remote evidence is refused.refused,plain terminal sessions are not supported.refused,no readable transcript. Never start fresh under that ID.not-found,session not found. No placeholder is created.sessionId must be a UUID.refused,ambiguous session transcript. Do not select the first folder.refused,session cwd unavailable. Unreadable/unusable JSONL itself usesno readable transcript.refused,unknown project. Do not add/import/create a project. Applies to new and closed resume; existing open-tab delivery remains available.global.hiddenProjects, withhiddenProjectKindsequal toarchive,delete, or absentrefused,project is archived. This check precedes known-project membership and directory existence, even when the project is absent fromallProjects. Do not unhide or restore it. Existing open-tab delivery remains available.allProjectsand has only anarchivedProjectsgroup recordrefused,project directory does not exist. Do not fall back from a missing worktree cwd to its parent.refused, respectivelyproject lookup unavailableorsession liveness unavailable. Do not treat exceptions as absence.expectedCwdis unknown/mismatchedthis session's cwd could not be determined; refusing rather than risk targeting the wrong session/expectedCwd does not match this session's actual cwd. Check before opening and again against the pinned actual cwd before writing/stopping.resolveDefaultSessionOptions(project)andlaunchNewSession, save the display name through the existing writer, wait, deliver required prompt. Return that ID withcreated:true.refused,new session rate limit exceeded. No renderer placeholder or PTY; no waiting for refill.resumed:true, second false if the first remains open. Prompts remain FIFO.refused,renderer not ready.refused,window not available. Do not create one. If the app is not running, the file waits for startup and the normal age check applies.refused,renderer unavailable, preserving known effects anduncertain:trueif necessary. Do not redispatch in the next document.refused,renderer action timed out; revoke the permit at the deadline. Preserve already-created visible session and evidence.refused,session action timed out,uncertain:true; retain submission/write and lifecycle evidence as in §4. No late recovery Enter or rollback kill.refused,Switchboard is closing.; no new launch, write or stop after admission is revoked.not-found,session not open here; if wholly unknown:not-found,session not found. Never resume to stop or deliver a note.refused,session busy; leave running and write nothing. Test both_cliBusyand readable descriptorbusy.refused, respectivelysession waiting on a dialog,session running a shell command,session state unavailable. Check these states after busy and before composer eligibility on every sample.refused, respectivelysession has unsubmitted inputorsession composer unavailable; no waiting, writes or kill.now - lastInputAt >= quietMsandnow < deadline. If still waiting when the deadline is reached,refused,session input not quiet; no deferred stop. §7 defines recheck and boundary behavior.refused,note step not finished; retain partial/submission evidence and delivery diagnostics. Leave running: no kill, Ctrl-C, ESC or queued future stop. No note-content check.refused,session busyif another turn has started; do not kill it. Other state/ownership failures retain their refusal.session target changedwhen the pinned stop object/generation is replaced.refused, respectivelysession open failed,session name could not be saved,session stop failed; retain backenderrorand any known effects.Known-project definition: a local project in the current main-side projects
query's
allProjectsset after normal project assembly. AnarchivedProjectsgroup record alone does not disqualify it:
showArchived:trueretains thesegroups (
archived-projects.js:68) and their local + action remains available(
public/sidebar.js:1304,:1740;public/dialogs.js:97). Use the same projectsnapshot service as
get-projects('both')(main.js:1339,:1358,:1364,:1382), extracted intoa callable helper rather than invoking an IPC handler or consulting only the
renderer cache. Match absolute paths with the existing
src/main/shared/normalize-project-path.js:3policy. Do not use a string-prefixcontainment check, the schedule registry as a substitute project allowlist,
or path existence alone as proof of a known project.
For new and closed resume, the same main-side snapshot must also retain
global.hiddenProjectsandglobal.hiddenProjectKindsprovenance beforefiltering:
session-cache.js:1076andarchived-projects.js:113remove thosepaths even from
allProjects. After obtaining a valid snapshot, check an exactnormalized local
hiddenProjectsentry first, then localallProjectsmembership, then directory existence. Both kinds
archiveanddelete, andlegacy entries without a kind, return
project is archived; this stable v1reason covers settings-hidden/removed projects. It takes precedence over
unknown projectandproject directory does not exist, including when bothhidden and
archivedProjectsrecords exist. A kind entry without matchinghiddenProjectsmembership is not a denial. Remote-qualified settings entriesdo not deny a local path. A hidden repository ancestor alone does not deny a
distinct worktree project that remains in
allProjectswith its own + action.If the query or its settings provenance cannot be read consistently, use
project lookup unavailable, never absence. This does not change existingopen-tab delivery or stop eligibility.
Strict closed-session resolution starts with
getCachedSession/getCachedFolder,excludes remote and non-top-level records, and checks the actual local JSONL.
If the cache lacks the ID, look for that normalized UUID basename directly in
local
PROJECTS_DIRproject folders, without descending into subagents orremote mirrors. This permits a readable transcript awaiting cache refresh.
Use bounded verified-cwd parsing (
extractVerifiedCwdFromJsonl) and map worktreecwd to its known project root with
resolveWorktreePath; keep the recordedcwd separately for spawn. A cached title/placeholder is not transcript proof.
Read errors are refusals, never absence. Reject duplicate case/folder matches.
Carry the selected transcript identity/cwd in the private permit; revalidate
its existence/readability and cwd before spawning. No caller controls a path
to a transcript. The finite directory lookup must respect the action deadline.
For a target without a proven local open PTY, check remote eligibility and
liveElsewhereV1Checked(§7) before classifying a missing transcript or unknownID, so a live schedule/background/external target gets its stated refusal.
This reads raw descriptors before host filtering even when no local PTY exists
and no prompt was supplied. For a closed resume, repeat this same v1 check
after resolution before renderer dispatch and immediately before spawning,
using normalized IDs, tracked schedule state and the conversation-specific
PTY exclusion policy. Unknown namespace identity refuses
session liveness unavailable; it never authorizes renderer work or spawn.Preserve
guardResumeas an additional renderer check withautomatic:true,allowBgAttach:falseandonBlocked. A liveness refusal never offers “Resume anyway.” This preventsknown live duplication; an independently launched external process can still
race the final check. There is no cross-process atomic claim on external CLIs.
6. Main/renderer implementation split
Main: request owner, validation, delivery and stop
Keep the watcher as the owner of one request and one result. Add testable
src/main/triggers/trigger-actions.jsfor v1 shape validation, target resolution,action orchestration and the limiter. Add
src/main/triggers/trigger-renderer-bridge.jsfor bounded renderer RPC andprivate launch permits. Inject filesystem, clock, project lookup, liveness,
renderer broker and stop backend so node:test can exercise the actual modules
without requiring Electron or spawning a real CLI.
Refactor existing local command/chain delivery into a callable internal routine
that returns its result object rather than publishing a result itself. Both
legacy and v1 call that routine; the outer file processor publishes exactly
once. Preserve existing legacy fields and behavior, including Escape and
remote paths. Do not emit a child trigger file for prompt or note delivery.
For resume/stop, acquire the existing session FIFO before deciding whether the
session is open. For new, main reserves
crypto.randomUUID()after validationand project resolution, then acquires the FIFO for that ID before dispatch.
The reserved ID is private implementation data, never a launch option accepted
from the trigger. After an open has been acknowledged, pin the real main entry
and acquire its existing tab-input mutex before readiness/delivery. For already
open sessions, acquire it before any delivery/stop checks. Hold both through
submission verification, final-note completion, stop and final-result publication;
release in
finally. Different tabs remain independent.Every open request obtains a renderer ensure-open acknowledgement, including
open-here: mode
reattachsimply acknowledges an existing renderer entry, orreattaches the main-owned PTY if the entry is missing. It neither spawns nor
selects. Main supplies the same pinned entry/generation; a different entry
cannot satisfy the acknowledgement. Stop needs renderer readiness but does
not need a renderer stop command.
Keep three identities in the private operation record:
requestedSessionIdtabKeyopenSessionsandopen-terminal. For an existing target, derive it from the map entry holding the proven session object, not from the requested ID orsession.realSessionId. Closed resume initially uses the requested ID; new uses the main-reserved ID.binding.effectiveId/readId()), process identity/start, session object, PTY and generation. It is checked throughcreateTriggerContextand is neither a renderer key nor an ACK supplied ownership claim.For an already-open requested A resolved to an entry keyed B, pin that entry
before dispatch and hold its tab-input mutex across ensure-open and delivery.
Send
requestedSessionId:A,tabKey:B, and B's pinned generation. The rendererchecks or recreates its ordinary entry under B; a missing renderer entry invokes
openTerminal(B, ..., {reattachOnly:true}, ..., permit). It must never try A orspawn another PTY. Main validates the ACK against
(requestId, documentEpoch, mode, requestedSessionId, tabKey, generation)and the same live session objectand PTY still stored under B. It then delivers using the pinned context for A,
which independently proves the effective writer. An A→B historical mapping is
not permission to skip that ownership proof. The same distinction applies when
descriptor lookup proves a writer whose session ID differs from the tab key.
The tuple is frozen for each RPC. If main observes its key now missing or
holding a replacement, or its pinned object/PTY/generation changed while the
ACK is pending, refuse
session target changed; do not dispatch to another keyor fall back to resume. A wrong-key/generation ACK cannot complete it. After a
valid ACK, existing pinned-delivery rules govern proven writer continuations
and same-object re-keys; they never authorize replacing the pinned PTY.
v1's absolute deadline starts when its validated JSON enters action processing,
before waiting for its session FIFO. It covers FIFO wait, renderer-ready wait,
RPC, launch, name persistence, tab mutex, readiness, delivery, note completion
and stop confirmation. Default and explicit timeout limits are §3's values.
Do not give each phase a fresh budget. A timeout waiting for FIFO uses
session action timed out; after acquiring, recheck age before the firstside effect. A timed-out queued lock waiter must be removed or have its eventual
release handled; abandoning a Promise.race must not leave the FIFO blocked.
Legacy keeps its current FIFO-outside-budget rule. Existing eight-file global
backpressure still applies; a caller must separately bound its result polling.
The main stop helper is extracted from
main.js:1964and remains the backendof the sidebar IPC. Trigger stop calls it directly on the pinned eligible
normal local entry, without an interactive confirmation. It does not use the
merged-row planner, stop siblings, invoke background job verbs or detach.
Check exact object, generation, PTY, current ownership, deadline, composer and
activity immediately before calling it. Use the existing process-exit routing
for normal renderer cleanup. Wait for the pinned entry's exit, not removal of
an unrelated map key. Natural exit after the note's confirmed completion counts
as closed; exit before its completion does not establish a finished note.
Sandbox control-directory protection
This is a filesystem mount boundary. When
SWITCHBOARD_SANDBOX_PODMAN=1exposes the Podman socket (
scripts/claude-sandbox.sh:267–270), the service canstart a container with host paths mounted writable, including the effective
triggers directory,
processed/results and action markers. The read-onlymounts below do not protect that control tree from access through the service;
this is the filesystem-boundary limit documented in
docs/sandbox.md:404–407.Keep the existing Podman opt-in and successful socket-exposing launch behavior
unchanged, including
test/sandbox-wrapper.test.js:1526–1528. Do not refuse alaunch because it exposes Podman, change saved settings, or retry without
Sandbox. The mount-plan protections and unsafe-layout refusals below still
apply to the sandbox's own filesystem mounts; they do not establish protection
against access through Podman.
Use one effective-directory resolver for the watcher and sandbox launchers,
based on
getTriggersDir()(trigger-watcher.js:934): the nonemptySWITCHBOARD_TRIGGERS_DIRoverride or~/.switchboard/triggers, resolved to anabsolute path in the main process. Share/export this resolver rather than
duplicating a default in
main.js. Main passes that exact path as the internalsingle-path environment value
SWITCHBOARD_SANDBOX_TRIGGERS_DIRto everyclaude-sandbox.shinvocation: normal new/resume/fork and scheduled launches(
main.js:3157,:3936). It is not a trigger field or a colon-separated extrabind. Standalone wrapper use falls back to the same override/default policy.
The wrapper checks both the lexical path and its resolved host path; it must
not resolve a relative app override against the session's different cwd.
Extend the wrapper's existing protection machinery (
path_forms,check_chain,protect_resolved,pin_protected_paths) to protect the effectiveroot and
processed/, including a separately resolvedprocessedsymlinktarget. Consider every host-backed writable mount, including project/worktree,
extra directories, CLI state and opted-in writable entries. For each resolved
control path, enumerate every sandbox destination through which any such
mount exposes it. Mount the whole control subtree read-only at each visible
destination; a path outside the sandbox stays unexposed. Pin every intervening
directory from each writable mount root to the protected subtree, so neither
the root nor an ancestor can be renamed and replaced. Protecting only existing
JSON files or the default spelling is insufficient.
Apply this after collecting all writable mounts and account for the final
depth-sorted mount order (
scripts/claude-sandbox.sh:845,:855). No later ordeeper writable mount may reopen any part of the control tree. Reject a cwd or
explicit extra bind at/below a control path, including its resolved aliases,
rather than grant that requested writable mount. Reject a protection path
traversing a symlink whose parent the sandbox can mutate, using
check_chain;protect a stable symlink target at every exposed alias. Refuse unresolved or
unrepresentable paths, or any mount layout whose final access cannot be proven
protected. A missing control directory below a writable mount must also refuse,
never silently skip protection: the host watcher/main preparation must create
it before a sandbox launch is admitted. Recheck the paths during the wrapper's
final mount-plan build. Refusal exits the wrapper with status 125 and a
claude-sandbox:diagnostic identifying the control path, before executing theCLI; never retry with Sandbox disabled. Do not alter saved launch options.
This boundary covers legacy triggers as well as v1 inputs, results and markers.
Host-side watcher publication remains writable. Existing sandbox namespaces
retain their original mounts; activating this protection requires fresh
sandbox launches, without terminating already-running sessions. The wrapper's
existing pre-flight/no-host-side-effects rule remains in force.
IPC broker
Add these explicit preload/main channels:
window.api.triggerActionsReady()/trigger-actions-ready(invoke)window.api.onTriggerSessionAction(cb)/trigger-session-actionwindow.api.triggerSessionActionResult(...)/trigger-session-action-result{requestId, documentEpoch, mode, ok, requestedSessionId, tabKey, generation, reason?, error?}; one correlated acknowledgement. For new/resume, main verifies the returned generation against its actual spawned entry under the permitted key; for reattach it must equal the pre-dispatch pinned generation.openTerminal(..., initialSize, triggerRequestId)/open-terminalThe broker issues a random request ID independent of the filename, stores the
deadline and exact expected target/mode/project, and marks each launch permit
single-use. Consuming a permit changes it from issued to opening; retain its
revocation/deadline state until settlement so the final pre-spawn check can
still reject it. Before dispatch, write the action marker in §8. Validate IPC sender
against the current window WebContents and its main frame, request ID,
document epoch, mode, immutable requested ID, permitted tab key and generation
as defined above. For new, the permitted key is the main-reserved ID, with
requestedSessionId:null. A reply cannot authorizeanother target, bypass a failed main check or create a second result. Duplicate,
late, wrong-sender and old-document replies are ignored.
Revoke readiness and pending permits on navigation/reload, render-process-gone,
window destruction, close commitment and deadline. Register cleanup once per
window and remove per-request timers/listeners after settlement. The broker
returns a Promise to the watcher; sending an event is not success.
The permit-bearing
open-terminalpath rechecks deadline/closing state,current target, local-only eligibility,
liveElsewhereV1Checked, exacttranscript and recorded cwd after awaited preparation and before actual
spawnPty. Preventconcurrent click/restore requests from spawning the same ID while preparation
is pending: share the main-side per-ID pending-open promise and recheck the
map after awaits. A trigger must fail closed if the other opener switches to
attach/remote/new behavior. A final synchronous deadline/permit check belongs
immediately before spawn; an expired request cannot start a late PTY.
Keep the
startsFreshrecovery for ordinary UI callers. A resume permit mustselect
--resume <exact id>or return a refusal; it must never select--session-id, a fork or a remote/attach branch. New permits use the reservednew ID and the normal saved defaults, including existing non-git worktree and
platform sandbox behavior. Reattach permits must find the existing pinned PTY
and use
reattachOnly; its disappearance is not permission to spawn.Renderer: ordinary visible lifecycle
Add a top-level
handleTriggerSessionActioninpublic/app.jsand registerthe preload callback once. The renderer validates request shape defensively,
honors deadline/cancellation after awaits, and calls existing lifecycle functions;
it does not write prompt bytes, decide final results or perform filesystem lookup.
For a closed resume, call
openSession→openSessionNowwithautomatic:true,activate:false,continuationResolved:true,allowBgAttach:falseand theprivate permit. Construct an exact-ID session view with
sessionIdandheadSessionIdboth equal to the requested ID andmergedMemberIds:[].In the trigger mode, bypass the dormant background-restore override at
public/app.js:2522; refuse its background provenance instead. Do not run thecontinuation chooser or remembered choice. Keep the resume guard and the
normal creation, size sync, MCP/sandbox state, persistence and polling paths.
For reattach, address
tabKeythrough the ordinary ensure-open lifecycle andthe
reattachOnlypath above; do not construct a closed-resume view for thehistorical requested ID. Return an explicit status containing the correlated
requested ID, tab key and generation, including existing-entry/reattach success.
false,nullandundefinedare not successful ACKs.If the wrapper joins an ordinary click's pending promise, verify the resulting
renderer entry under the permitted tab key and main-owned generation before
building the structured ACK; the ordinary promise's possibly undefined return
is not the ACK itself.
For new, extend
launchNewSession(project, options, restoreSessionId, flags)with internal flags for the main-reserved ID, initial display name, permit and
activate:false; ordinary callers preserve existing behavior. Still useresolveDefaultSessionOptions(project)and the normal pending/sidebar/terminalpath. Persist the name through the existing session writer, targeting the new
ID only (
main.js:2271), before delivering the prompt; reflect it in pendingrenderer metadata so it survives refresh. No trigger string is interpolated
into a shell command. Keep the session visible on post-spawn failure; clean
up failed placeholders according to normal lifecycle, without selecting them.
Neither trigger open calls
showSession, including on errors. In grid mode,use the existing card construction/layout without
focusGridCard; preservethe current focused card/view. An optional internal flag must not become a
caller-controlled wire launch option. Do not acknowledge success until the
renderer entry is registered, main's open result is successful, and normal
persistence scheduling has happened. Main cross-checks the entry/generation
before continuing to prompt delivery.
jbr-sekoia commentedon Nov 5, 2026
Part 2/2
7. Readiness, prompt delivery and final note
Do not use a spawn ACK, empty composer or
_cliBusy === falsealone as proofthat a newly started CLI is ready. A CLI can still be starting before its
descriptor exists. There is no fixed startup sleep.
Reuse these existing waits with the same pinned delivery context:
waitForIdle(trigger-watcher.js:986) for the busy-flag initial wait.waitForComposerFree(:374): pending input must be zero and the existingquiet window must pass (default 3000 ms). Missing composer state remains busy.
waitForCliIdleAfter(:655), usinggetBusyFallSettleMs(default 300 ms),for descriptor readiness and dialog/shell/busy refusal. Keep the existing
last-moment liveness, ownership and deadline checks (
:1544,:1587).submitWithVerify(:740): text plus discrete Enter, current composer check,busy observation and at most its existing recovery Enter. Preserve the
evidence classifications and the
/compactconfirmation rule.waitForBusyFall(:882) with the actual note Entertimestamp and the existing transcript/descriptor completion evidence.
Reuse
waitForPendingConfirmation(:486) and the transcript own-entryevidence when needed; do not create a second PTY sender.
Force
wait:"idle"internally for v1 prompts and before. Open-here keeps theexisting delivery fallback if no usable descriptor is available at the first
read; its legacy direct-key ownership fallback also remains, except that v1
sandbox targets require the namespace proof below before any write or stop.
A newly spawned new/resumed session instead waits for a usable descriptor observed for the
new launch, with integer
statusUpdatedAt >= launchStartedAt, then obtainssettled idle before typing. Reuse
waitForCliIdleAfterwith this lower bound(adjust the strict
>boundary to include equality without changing legacysemantics). If it returns
available:false, keep polling within the samedeadline for this startup mode; do not accept the legacy immediate fallback.
Record
launchStartedAtin main immediately before the actual spawn, not atrenderer dispatch or after PTY registration; keep it in the private operation
record for the startup waiter.
Pinning still checks writer identity/generation where available, so an old
descriptor is not startup readiness. Once read, losing the descriptor is
not-idle as in the existing helper. If no usable fresh descriptor arrives,
refuse
session readiness unavailable. Idle without prompt does not needthis prompt-readiness gate: opening alone is the requested operation.
V1 liveness before resume
Add
liveElsewhereV1Checkedincli-session-state.jsas a v1-only checkedmain-side entry point. Its result is
{known:true, live}for a proven livewriter,
{known:true, live:null}for known absence, or{known:false, reason}when absence cannot be established. It supplements the schedule/background
and conversation-specific exclusion rules of
liveElsewhereChecked; callingthat existing host-filtered helper alone is insufficient. The ordinary IPC,
global status cache, external-session readers and legacy triggers keep their
current behavior.
Use a shared v1 raw descriptor reader for this check and the pinned sandbox
reader below. Read the configured sessions directory and actual files through
the real descriptor/state parsers before any host-PID liveness/start filter.
Keep matching normalized session-ID candidates even if their raw PID is absent
on the host or belongs to a process with a different
procStart(
cli-session-state.js:799,:818). Do not source candidates fromgetStatus,readAllDescriptors, the filtered writer snapshot or the existing checkedhelper's null result. Descriptor age or idle status does not establish death.
Inject filesystem, procfs and clock edges; scans and rechecks share the action
deadline. Unreadable/malformed files or incomplete scans that could conceal a
matching descriptor cannot establish absence.
Check tracked schedule runs even without a descriptor. For each matching raw
descriptor on Linux, search host-visible processes independently of any local
PTY or saved sandbox launch flag. Read
status(NSpid),stat(start time)and
ns/pid; match the innermost namespace PID and comparableprocStarttothe raw descriptor, retaining host PID/start and namespace identity separately.
A native host writer must also have a proven matching host identity; a
same-number host process alone is not proof. A namespace writer may have
hostPid != raw.pidand a PID namespace different from the main process's.Do not restrict this external search to descendants of this app or a pinned
PTY. Require a unique consistent mapping, then re-read the descriptor and
process/namespace evidence to reject file replacement, exit or PID reuse.
Apply the conversation-specific PTY exclusion only to a writer proven to
belong to that conversation's local PTY; no local PTY means no such exclusion.
An unrelated local tab cannot hide an external writer.
A proven external writer yields
session live elsewhere; a proven livebackground writer and tracked schedule retain §5's specific refusals. If any
matching candidate's identity remains unresolved and there is no proven live
target already requiring refusal, return
known:false, mapped torefused,session liveness unavailable. This includes zero/multiple hostmatches, missing/unsupported start or namespace evidence, unreadable procfs,
scan truncation/deadline and changes during revalidation. Neither host-PID
absence nor a start mismatch proves a namespace writer dead; even a complete
host scan with no match cannot discard such an unresolved raw descriptor.
Known absence requires a complete readable descriptor scan with no unresolved
matching candidate and no live scheduled/background/external target. A proven
live target may be refused without completing an otherwise uncertain scan;
uncertainty never grants a launch permit.
Run this gate at all §5 admission/resolution and §6 pre-spawn checkpoints,
including open without prompt. A pre-dispatch refusal makes zero renderer
bridge calls and zero spawns. The renderer guard is an additional check and
cannot replace or override this main-side verdict. The no-prompt exemption
from post-spawn prompt readiness does not exempt pre-spawn liveness.
Sandboxed writer and status source
Sandbox launch keeps
--unshare-all(scripts/claude-sandbox.sh:848).The CLI writes its namespace-local PID (
docs/sandbox.md:411).HEAD's
handleFile/readWriterDescriptorsdiscard a descriptor if thatnumber is not live on the host (
cli-session-state.js:520,:889), anddeliveryProcessIdentitycomparesprocStartat the same host PID(
:908). Main suppliesgetStatus(id)to the watcher (main.js:4014).Neither that cache nor a successful renderer ACK is a sandbox readiness source.
Add a v1-only PTY-scoped descriptor observation in
cli-session-state.js,used by
createTriggerContextfor sandbox targets and backed by the sharedraw reader above. Its delivery proof additionally requires a pinned local
PTY; the pre-resume liveness check does not. Inject procfs reads and the clock
for tests. Bound scans by the action deadline; an incomplete or unreadable
scan cannot establish uniqueness. Keep global status-cache, external-session
and legacy trigger semantics unchanged.
Main records the actual sandbox flag, session object, PTY object, generation,
host PTY PID/start identity and
launchStartedAtfor the permitted launch.For each candidate raw descriptor, prove one unique host writer as follows:
/proc/<hostPid>/status(NSpid),stat(parent and starttime), and
ns/pid(namespace identity). Its innermostNSpidmust equalraw
pid, and its host start time must equal the descriptor's comparableLinux
procStart. The writer must be in a different PID namespace fromthe host PTY, reached through that PTY's host descendant tree. A same-number
host process, matching session ID or descriptor timestamp alone is no proof.
identities, retaining the existing nearest-interactive-writer and
nested/background exclusions. Require the requested/effective conversation
identity under the existing continuation rules. Reject multiple eligible
writers, unreadable links, missing/unsupported start identity or namespace
evidence; never choose the first match. A descriptor filename collision
between sandboxes does not relax these checks.
reject an exit, PID reuse or changed file during observation. Keep the raw
namespace PID separate from the normalized host PID. Bind the resulting
proof to
(session object, PTY object, generation, host PTY PID/start, namespace identity, host writer PID/start, raw PID); never cache by sessionID or raw PID alone. Revalidate it on every readiness sample and immediately
before text, Enter, recovery Enter or stop; invalidate it on any binding
change, process exit or loss of proof.
Return the parsed status,
statusUpdatedAt,waitingForand proven writerfrom that same observation. Use this source for the pinned v1 context's
getCliStatusand writer/ownership inspection throughout startup, delivery,verification and final-note/stop checks; do not switch back to the host-only
getStatus(id)after startup. Any normalized descriptor passed to existinghost-ancestry helpers carries the translated host PID and validated start
identity; the raw on-disk file is never rewritten.
A newly spawned sandboxed new/resumed session must still meet the fresh
statusUpdatedAt >= launchStartedAt, continuous idle-settle and empty/quietcomposer gates above. Losing mapping/descriptor proof resets the idle interval.
Missing, ambiguous or unprovable mapping supplies no usable state and never
enables the direct-key or descriptor-absent fallback for a v1 sandbox target.
Poll only within the existing action deadline: startup without proof refuses
session readiness unavailable, preserving the ID and true launch evidenceand leaving the session open. During delivery, loss of proof suppresses further
bytes under the pinned ownership refusal;
stop.beforeretainsnote step not finishedwith diagnostics and no kill. Bare stop withoutusable proven state refuses
session state unavailable. No unsandboxed retry,PID-namespace sharing, renderer readiness claim or fixed sleep substitutes for
the proof. Open without prompt retains its ACK-only success rule after
the mandatory pre-spawn v1 liveness checks; it skips only prompt readiness.
Delivery results and final note
Preserve exact existing delivery reasons when an open prompt cannot be sent,
including the dialog reason
the CLI reports a dialog open (waiting); nothing was written into it,the CLI still reported a turn running (busy) at the deadline; nothing was written,the CLI never reported idle before the deadline; nothing was written,the CLI was idle only briefly before the deadline; it never held long enough to settle; nothing was written,and the composer reasons from
trigger-watcher.js:334. Existing deliveryfailures lacking
reasonuse their exacterroras the v1 reason. Thismapping wraps evidence; it does not rewrite legacy results.
For v1, thread the absolute action deadline into submit verification, text,
Enter and recovery Enter; the current single-command call sometimes passes
no deadline for ordinary commands (
trigger-watcher.js:1608). Do not inheritthat unbounded tail for v1 or perform late writes after the result is terminal.
Keep legacy call behavior. A timed-out operation must actually cancel further
work, not merely resolve its outer Promise.
Inspect the complete
submitWithVerifyreturn before mapping a v1 result.Its
timedOut:true, or an action-deadline expiry before verification completes,overrides ordinary single-command success and the fallback mapping of delivery
errors. For open after Enter, use §4's
session action timed outrefusal withuncertain:trueand the actual submission classification; preservetimedOut:true, Enter timestamp, retries, wait timings, partial/write andlifecycle evidence. Do not infer confirmed submission from an empty composer.
For stop.before the same timeout maps instead to
note step not finished,with
uncertain:trueif Enter may have taken effect and the underlying timeoutevidence under
delivery. In both cases, settle once, cancel pending polls andrecovery/stop continuations, and allow no later Enter, text, cleanup bytes or
kill. Finishing the normal verification window within budget without a
deadline timeout may still produce an assumed submission under existing rules.
beforeis an internal one-step delivery with awaitForFinalTurnmode.The final step must have positive submission/turn evidence and completed-turn
evidence. A chain's last-step success, an old idle descriptor, an empty
composer, or
waitForBusyFall.source === "no_rise"is insufficient. Continuewaiting for a post-Enter idle/observed busy-fall, or for the note's own transcript
entry followed by a closed, quiet turn under existing transcript rules. Never
turn a
no_risereturn into permission to kill. The same global deadlinecovers initial busy waiting and this final wait. On failure/timeout, publish
note step not finished, retain underlying evidence underdelivery, cancelthe stop continuation, and leave the process alone. Switchboard does not inspect
whether a note was written or what it says.
Bare-stop composer gate
Before bare stop require readable idle state, no busy flag and an empty quiet
composer. Empty and quiet are independent:
noteUserInputpreserves the lastkeystroke time after deletion (
src/main/terminals/composer-state.js:284),and
waitForComposerFreechecks both conditions(
src/main/triggers/trigger-watcher.js:385). Use the existinggetQuietMs()configuration (default 3000 ms) and
lastInputAt || 0convention.At admission and each wait sample, first validate the pinned target and current
ownership, then apply §5's busy and descriptor-state refusals, then inspect the
composer. Missing/unobservable state refuses immediately with
session composer unavailable; positive pending input refuses immediatelywith
session has unsubmitted input. An observable empty composer withrecent input waits for silence. Keep both locks and the original action
deadline. During this quiet wait, check the deadline before each sample; expiry
takes precedence over newly sampled state/composer diagnostics. Resample all
guards within budget; another keystroke that leaves it empty restarts
the quiet interval, pending text or lost observability refuses immediately,
and new busy/waiting/shell activity refuses without waiting for it to finish.
Preserve the composer's existing diagnostics under
detail.Equality at
lastInputAt + quietMssatisfies silence only whilenow < deadline. If the deadline arrives while waiting, including exactly atthe quiet boundary, settle once as
refused,session input not quiet,submitted:"no",wrote:false,steps_total:0, without uncertainty or kill.Cancel polls/continuations so later silence cannot stop the session. Immediately
before invoking the backend, repeat target, state, composer and deadline checks;
if only a new empty-composer quiet interval is required, return to this bounded
wait, never reuse a previous free sample. The existing post-kill confirmation
timeout remains
session stop not confirmed.Before stop-after-note use its completed-turn evidence; a stale busy
descriptor already accounted for by the existing transcript fallback need not
block forever, but a newer busy/shell/waiting transition, newly unsubmitted
input or changed writer invalidates permission to stop. Revalidate the same
pinned PTY immediately before kill. No interrupt byte is sent to make it idle.
8. Limiter, publication and interruption
New-session limiter: one in-memory bucket per watcher instance, capacity 3,
refill one token every 20,000 ms, continuously up to capacity. Three valid
new-session launches may burst; a fourth without refill refuses immediately.
Reserve atomically after validation/project checks and before renderer dispatch.
Refund only a definite pre-spawn failure; retain the token once a spawn may
have happened. Resume, open-here, stop and manual + clicks consume no token.
The bucket resets on process restart. This is a fixed v1 creation-rate bound,
not #380 admission, a concurrent-session limit, a user setting or a refill queue.
For v1 only, maintain one small atomic action marker at
processed/<trigger-uuid>.action.jsonbefore sending renderer work, deliveringtext or initiating stop. It contains version, action/mode, normalized request
identity, target/reserved ID, deadline and confirmed lifecycle phase/evidence;
it need not contain the prompt. Fail to create the marker: refuse
action state could not be savedand take no action. Update confirmed phaseafter spawn/stop request as possible, retaining uncertainty about any gap.
This is a process-restart guard, not an exactly-once or power-loss guarantee.
Publication order is terminal result temporary write → existing rename retry
→ remove input → remove action marker. Once a result is committed, it is
immutable and wins over any leftover marker or input. If result publication
fails, retain marker/input and suppress replay in memory; log the failure.
Do not use HEAD's delete-even-on-publication-failure rule for v1. If input or
marker deletion fails, the existing result still prevents replay after restart.
No automatic retention/pruning policy is introduced for results.
On startup, inspect v1 markers as well as root trigger files through the
normal dispatch/backpressure path, without handing marker files to legacy
delivery. A marker without a terminal result produces
refused,reason:"interrupted by restart",uncertain:true, preserving its target IDand recorded effects. Do not resend a prompt, resume again, create a second
new session or finish a pending stop. Recover orphan markers even if their
root input has disappeared. A result plus leftover input only needs cleanup.
Unclaimed files written while the app was down use ordinary startup processing
and the age check. Marker recovery takes precedence over the age check.
During an orderly close, revoke new work/permits and settle cancellable
operations where possible. If process exit prevents publication, the marker
covers the next start. Persisted working-set restoration remains ordinary
restoration and does not constitute replay of the trigger's prompt or stop.
A caller polls for its result with a finite bound; a useful default is the
configured max age plus its action timeout plus a small publication allowance.
Global queue load, an app that stays down, renderer unresponsiveness or disk
failure can still outlast that bound. Missing result means unknown, never
successful delivery or a safe instruction to repeat under a new UUID. The
caller should inspect the session/result after restart. Changing the caller's
wake script is outside this implementation.
9. Implementation files and checks
src/main/triggers/trigger-watcher.jssrc/main/triggers/trigger-actions.js(new)src/main/triggers/trigger-renderer-bridge.js(new)src/main/triggers/trigger-context.jscli-session-state.jsliveElsewhereV1Checkedwithout a local-PTY requirement, and PTY-scoped verified Linux namespace-to-host writer translation in §7, with injectable filesystem/procfs/clock edges. Preserve existing global and legacy readers.main.jsscripts/claude-sandbox.sh,test/sandbox-wrapper.test.jsdocs/sandbox.mdpreload.jspublic/app.jspublic/dialogs.js,public/terminal-manager.js,public/grid-view.js.ai/contexts/trigger-watcher.md.ai/contexts/ipc-bridge.md,.ai/contexts/cli-session-state.mddocs/automation.md,docs/e2e.mdCHANGELOG.md## Unreleased→### New, entry below.Changelog entry:
Runnable acceptance criteria and tests to add
Use node:test under
test/, temporary directories under the runner's TMPDIR,fake PTY handles, injected clocks and controlled async edges. No live user
session, real CLI invocation, global descriptor directory or real triggers
directory is part of unit validation. The isolated real-bwrap readiness fixture
below runs a fake descriptor writer in its own HOME with real procfs evidence,
never the real CLI. Exercise shipped functions: renderer
tests load
handleTriggerSessionAction,openSession,openSessionNowandlaunchNewSessionas needed withloadAppFunctionsfromtest/app-source.js:99and
setupSidebarDom. Do not copy implementations into tests. Followtest/open-session-once.test.js:60; disable installed spies when declarationswould overwrite them. Assert both effects and forbidden calls.
test/trigger-actions-schema.test.js(new): table-test all three examples, omissions, both targets, every unknown field, wrong version/action, session UUID casing/shape, canonical lowercase filenames, path type, name controls, text limits/controls and timeout boundaries. Assert exact reason/envelope.test/trigger-actions-open.test.js(new): real watcher with fake lifecycle bridge. Existing open target sends once without opening; closed transcript opens once before prompt; no-prompt resume writes zero bytes. Result flags and ID match actual effects.test/dom-trigger-actions.test.jsandtest/trigger-renderer-bridge.test.js: use realcreateTriggerContext, real renderer lifecycle functions and broker with fake IPC/PTY boundaries. Seed UUID A→B continuation proof and historical resolver; main and renderer maps contain only B, with pinned generation G and observed writer B. Request A; assert RPC/ACK carry requested A, tab key B and G, one delivery to the original PTY, resultsessionId:A,resumed:false, and zero spawns. Remove only renderer B after readiness, repeat with another request UUID, and assertopenTerminalreceives B withreattachOnly:trueand re-creates B without spawning. Also cover descriptor-proven writer A in a tab keyed B.test/trigger-actions-target.test.js(new): disposable cache/transcript/project fixtures cover cold-cache readable transcript, known missing/empty/denied/malformed JSONL, unknown ID, duplicate folders/case, missing worktree, unverified cwd and unknown project.test/dom-trigger-actions.test.js: a local project with only anarchivedProjectsrecord remains inallProjects. Compare open-new against the real + → Claude/defaults path with identical saved settings; both launch with the same permissions/sandbox/worktree/add-dirs and normal archive effects, allowing the trigger's specified name and nonactivation. Also admit closed resume with a readable transcript; toggling archive-list visibility does not change eligibility.project is archivedrefusal for a known group archive, different defaults or extra archive-setting mutation compared with +.hiddenProjectsfixtures with kindsarchive,deleteand absent, each absent from assembledallProjectsbut retained in snapshot provenance. Exercise new and closed resume, with both existing and missing project directories and an overlapping group archive. Each returnsrefused,project is archived, before membership/existence checks, with zero lifecycle calls or settings writes. Test a kind-only record, remote-qualified same path, a visible worktree with only its repository hidden, a truly unknown path, and unavailable provenance.unknown project/missing-directory or launches; a nonhidden known project is denied; missing provenance is treated as absence; wrong stable reason.test/cli-session-state.test.js: initialize the real descriptor module on a disposable sessions directory and call the actual v1 target resolver andliveElsewhereV1Checked, injecting only filesystem/procfs/clock edges, not liveness verdicts. Seed a known closed transcript/project, no local PTY for S, and an actual raw descriptor for S with inner PID N and live host writer H != N, matching start/namespace evidence. First make host N absent; then occupy host N with an unrelated process with a different start. For each case exercise open with prompt and open without prompt: both returnrefused,session live elsewhere, with zero renderer bridge calls, zero spawns and zero bytes. Also cover an unrelated local tab and namespace/ID collisions.session liveness unavailablewith zero bridge calls/spawns/bytes. A complete readable scan with no matching descriptor or live schedule admits ordinary closed resolution. Repeat checks with a candidate appearing after resolution before dispatch, and after dispatch during awaited open-terminal preparation: the former still makes zero bridge calls; the latter revokes the permit and makes zero spawns. Assert the live/unknown reason at the final guard and no late launch.{known:true, live:null}, a fallback filtered reader authorizes resume, an earlier absence result is reused after awaits, the final guard uses a different reader, or a rejected/expired request launches later.test/dom-trigger-actions.test.js(new): load real renderer functions; prove exact requested ID despite merged head, saved continuation choice and dormant bg record; default saved settings reach openTerminal.showSession/focusGridCardspies remain untouched. Manual + and click still select normally.test/trigger-actions-readiness.test.js(new): after spawn, descriptor absent → old idle → busy → waiting → stable fresh idle. Use controlled time and assert zero bytes until fresh settled idle and free quiet composer. Remove/loss/flicker cases timeout without writing; no-prompt opening bypasses this gate.test/cli-session-state.test.js: initialize the real descriptor module on a disposable sessions directory, use realcreateTriggerContextand shared delivery with injected clock/procfs edges, and run sandboxed new and resumed modes. Write an actual raw idle descriptor with inner PID N and host writer H != N, matchedprocStart, namespace and host ancestry to pinned PTY P/generation G. Cover host N absent and host N occupied by an unrelated process with a different start; neither must gate the mapped H. Advance from absent → stale → fresh busy → waiting → stable fresh idle, with an empty/quiet composer; require no bytes before proven settled readiness and exactly one prompt/Enter pair afterwards, with correct launch/result evidence.getCliStatus/observeTabWriterwith a fabricated ready result, bypasses raw parsing/filtering, times out despite valid H, or uses N's host liveness as writer proof.NSpid, namespace,procStart, parent/start evidence or descriptor identity; make procfs unreadable/incomplete, add a second eligible writer, a nested writer, another sandbox with the same inner PID, or replace the pinned PTY object or generation while polling. Also lose proof between idle samples, between text and Enter, and before recovery Enter/final-note stop. Advance past the action deadline and supply a late valid mapping.session readiness unavailablewith retained launch/ID evidence; late proof changes a terminal result; global/legacy readers acquire translated v1 state.test/trigger-actions-readiness.test.js: reuse the sandbox rig/usable-userns gate fromtest/sandbox-wrapper.test.js:51,:1638, extending the rig for an async node-pty launch. Under disposable HOME/project/sessions/triggers, run the shipped wrapper around a fake CLI descriptor writer, through the real descriptor module, context and watcher; do not stub procfs or status. Parameterize new/resume, record the actual host PTY/generation and distinct host/inner writer PIDs, and have the fake writer publish its own/proc/self/statstart identity and controlled busy/idle transitions. Confirm one prompt reaches only that PTY after settled fresh idle, withopenedand correctcreated/resumedflags. A second run publishes a mismatched start identity and must expire withsession readiness unavailableand no bytes. Tear down only fixture processes. Linux CI requiresSWITCHBOARD_REQUIRE_REAL_BWRAP=1using the same mandatory gate astest/sandbox-wrapper.test.js:1535.submitWithVerifywrites text and its first Enter before the deadline; hold all descriptor/busy/transcript reaction absent and expire the action deadline during verification, before its recovery window. Run open-here, confirmed resumed and confirmed new variants. Assert exactly one text/Enter pair,outcome:"refused",ok:false,reason:"session action timed out",uncertain:true,submitted:"assumed",wrote:true, text/Enter flags,steps_total:1, retained ID and true lifecycle flags where applicable. Advance beyond both verification windows and inject late reaction. Repeat for stop.before, expectingnote step not finishedand underlying timeout evidence.opened/ok:true, false no-write/confirmed evidence, lost spawn evidence, a second Enter/text/cleanup/kill, a changed terminal result or any automatic retry after timeout.test/trigger-actions-stop.test.js(new): idle bare stop uses shared sidebar backend once, then waits for pinned exit. Busy, shell, waiting, unknown and pending composer all refuse with zero writes/kills.pending:0andlastInputAt:t; admit bare stop at t+100 with readable idle and no busy flag. Assert no kill at t+quietMs-1, exactly one backend call at t+quietMs when the deadline is later, andclosedonly on pinned exit. Repeat with deadlines before and exactly at t+quietMs:refused,session input not quiet, no writes/kill/uncertainty and no late kill after further clock advances. Also test admission exactly at the quiet boundary.session composer unavailable. Add pending text: immediatesession has unsubmitted input. Another type/delete while waiting resetslastInputAtand the boundary; a busy/waiting/shell/unknown transition uses §5's state reason and cancels the stop. Mutate the empty composer's timestamp or observability after an eligible sample but before the backend guard; require re-wait/refusal under the same deadline. Cover a nondefault configured quiet window. All cases write zero bytes and never kill on a refused path.test/trigger-actions-rate.test.js(new): fake clock admits three new requests, fourth refuses, 19,999 ms does not refill a full token, 20,000 ms admits one. Parallel dispatch cannot overdraw; definite pre-spawn failure refunds, uncertain/spawned failure does not. Resume/stop/manual paths are exempt.test/trigger-renderer-bridge.test.js(new): fake WebContents and ipcMain registration test not-ready→ready, no window, never ACK, navigation, renderer death, closing, wrong sender/main frame/epoch/ID, duplicate/late reply and revoked permit. Test final pre-spawn expiry after deferred MCP/setup, and concurrent ordinary open plus trigger.test/trigger-actions-recovery.test.js(new): real temp-directory watch/startup scan with fake bridge. Simulate shutdown by leaving markers at claimed, spawned, prompt-attempted and stop-requested phases, then start a fresh watcher. Include result+input+marker, orphan marker, failed rename/unlink and duplicate UUID; uppercase filename is refused rather than executed.test/session-launch-guards.test.jswith executable extracted/injected permit guard tests and retain current ordinary fresh-fallback tests.--session-id; ordinary retry stops working.test/sandbox-wrapper.test.jswithout exposing the Podman socket, usingmakeRig({recordArgs:true}),parseMounts,accessAtandmountAt(:51,:124,:136,:143). Cover default and custom control roots outside mounts, inside project/worktree or an extra directory, with spaces, stable symlinked extra-root aliases, multiple overlapping roots andprocessedresolved elsewhere. Check root/results/markers read-only at every visible alias, ancestor pins ordered before protection, and no final writable descendant override. Cover direct/aliased cwd or extra bind at/below the control root, mutable symlink chains, missing visible root, unresolved paths and paths the transport cannot represent: exit 125 before fake CLI execution.makeRig({claudeRunsArg:true})and the existing usable-userns gate (:1638): from the fake CLI attempt trigger create/atomic rename-in, result/marker overwrite/unlink, and root/processed/ancestor rename-and-replace through each exposed alias. All attempts fail; host sentinels remain unchanged; unrelated project writes and the session's transcript still land. Host can still create/publish a trigger/result. Linux CI requiresSWITCHBOARD_REQUIRE_REAL_BWRAP=1so this proof cannot silently skip (:1535).trigger-watcher,trigger-context,trigger-single-readiness,trigger-every-step-readiness,trigger-background,trigger-remote,trigger-escape-key,trigger-continuation-delivery,open-session-once,resume-guard,stop-session-ui,confirm-and-stop-sessionandsession-launch-guardstest files.For an implementer with an isolated test environment, the new targeted suite is
runnable as:
node --test --test-concurrency=1 test/trigger-actions-*.test.js test/trigger-renderer-bridge.test.js test/dom-trigger-actions.test.js test/sandbox-wrapper.test.jsThe control-directory protection criteria above apply only without an exposed
Podman socket. No test asserts protection of triggers, results or markers when
the socket is exposed. Preserve the existing Podman opt-in test unchanged,
including the successful launch and read-only socket-directory bind asserted
at
test/sandbox-wrapper.test.js:1526–1528; it does not prove control-directoryprotection through the Podman service.
After those pass, use the repository's
task checkgate on a suitable machine.IPC/visible-session journey
An Electron IPC journey is relevant because jsdom cannot prove that the real
preload subscription, broker, native window and renderer entry cooperate.
docs/e2e.md:90requires isolated HOME/data/triggers and:98restricts currentjourneys to plain terminals. Keep existing journeys unchanged. Document a
narrow fixture exception for this journey: the session command is intercepted
by a saved project pre-launch prefix which runs a fake wrapper, never the
real CLI. Add
e2e/trigger-actions.spec.jsand a test-only fixture script suchas
e2e/fixtures/trigger-session.cjs; add no production test IPC or map hooks.Run this journey in Linux CI with the existing
e2e/fixtures.jsisolation.Before publishing the trigger, save the fixture project's normal settings
through the existing API: sandbox off, MCP emulation off, and
preLaunchCmdequal to the test runner's Node executable plus the fake script's absolute
path. Use paths accepted by the existing prefix allowlist at
src/main/terminals/pre-launch-cmd-guard.js:12; do not weaken that guard. Mainconstructs its normal command (
main.js:3095,:3107,:3170), but Node runsthe fixture script with
claude --resume <id>as arguments. The script recordsargv/cwd under the disposable HOME, stays attached to stdin and never executes
those arguments. Verify the saved prefix before triggering; an unexpected
executable/argv is a test failure. The fixture has no CLI credentials.
Seed a known local project and closed transcript, keep a plain terminal selected,
then publish an open-resume request without prompt in the fixture triggers
directory. Assert one attached ordinary terminal and selectable sidebar row,
unchanged selected session, focused control and native focus-call count, the
fake script's exact resume argv/cwd, and an
openedresult withresumed:true,wrote:false. Repeat with another request UUID for the same ID and assertresumed:falseand one wrapper process in total. Click the new row afterwardsand assert its ordinary terminal becomes visible without another launch.
Use locator assertions/
expect.poll, never fixed sleeps. Prove the journeygoes red by dropping the preload subscription or selecting the session on ACK.
This journey measures production watcher/IPC/PTY/renderer integration with a
fake command. Descriptor startup readiness, prompt bytes, final-note completion
and termination remain in the executable node:test cases above; the journey
does not claim those CLI behaviors. A live CLI smoke test is optional, on a
separately authorized disposable installation, and is not required for this spec.
10. Owner questions
None blocks v1 implementation. The fixed three-token/20-second limiter,
nonactivating window/tab policy, exact-ID closed resume and conservative
interruption behavior are the decisions in this specification. Recommendation:
ship those defaults with no settings surface; any alternative policy belongs
in an explicitly scoped follow-up. #380 launch options and the caller's wake
script remain outside this change.
11. Appendix A — approved v1 scope and owner decisions
Inventory at e0f144b:
Decisions:
outcome: opened | closed | refused | not-found, plusreasonandsessionId(the new id for "new"). The trigger file is removed after the result is written. A caller waits on the result with a bound.beforeprompt delivered first and waited until the turn ends within the deadline (chain mechanism); then the sidebar Stop path. On timeout: refused "note step not finished", session left running. Refused: busy without before, remote sessions, schedule runs. Switchboard does not check any note content.Podman decision: F9 is resolved by documentation, not by refusing launches: when SWITCHBOARD_SANDBOX_PODMAN exposes the Podman socket, the trigger-directory protection does not hold, exactly as the filesystem boundary already documented in docs/sandbox.md:404-407. The spec states this limit in its guarantee (section 1 and 6), the docs to update name it (docs/sandbox.md and .ai/contexts/trigger-watcher.md), and no test asserts protection in the Podman case. The existing Podman opt-in and test/sandbox-wrapper.test.js:1526-1528 stay unchanged.
(sandbox): keep sandboxed sessions out of the triggers directory (#1022)
(sandbox): keep sandboxed sessions out of the triggers directory (#1022…
devsuitup commentedon Nov 8, 2026
Slice A (sandbox protection of the effective triggers directory) merged in #1024. Follow-up: #1031. The remaining slices stay open: versioned dispatch, renderer bridge, limiter, PID translation, docs/automation.md. Their spec is spec-1022-part1/part2, archived locally under archive-1022a.
(triggers): accept versioned trigger files and answer them with typed…
(triggers): accept versioned trigger files and answer them with typed…
(triggers): keep a held conversation when a trigger resume is refused (…
test(triggers): extract the shipped get-projects helper with its hand…
fix(triggers): keep a held conversation when a trigger replaces a clo…
(triggers): open or create a session from a v1 trigger (#1022) (#1054)