Skip to content

(triggers): resume a closed session visibly and deliver a prompt to it #1022

Open

Description

@jbr-sekoia

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

What is needed

  1. 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) and prompt (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|false and the usual submitted value.
  2. Reuse (triggers): let a session open sister sessions and follow their state #380's guards.
    • Admission and the maxTriggeredSessions cap: 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.
  3. Resume guard. The cases in public/resume-guard.js and .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.
  4. 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

Related: #380, #546, #822.

Activity

  1. jbr-sekoia commented on Nov 5, 2026

    @jbr-sekoia
    CollaboratorAuthor

    Ready for implementation. Converged specification for #1022 (spec review PASS at round 5, baseline e0f144b). It supersedes the issue body's "What is needed" section.

    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-policy
    snapshots. 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. This
    applies 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=1 exposes the Podman socket, this
    protection 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; socket
    exposure 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 focus field in v1.

    2. HEAD inventory and implementation consequences

    Every reference below is relative to the baseline above.

    Existing behavior Evidence at HEAD Consequence
    The main process starts the watcher with the active-session map, CLI status, live-descriptor reader and ID resolver. main.js:4013; src/main/triggers/trigger-context.js:40 Extend this dependency injection; keep filesystem processing in main.
    Directory defaults to ~/.switchboard/triggers, with SWITCHBOARD_TRIGGERS_DIR override. 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, :1023 Reuse configuration and limits.
    Regular-file, 64 KiB size, mtime age and JSON parsing guards precede delivery. Parse failure gets one retry after 50 ms. Default age limit is 300,000 ms. src/main/triggers/trigger-watcher.js:1108, :1126, :1133, :1152, :962 Keep these guards for both families.
    Legacy input is sessionId plus command, chain or key. Missing local PTYs may use descriptor ownership fallback or the enabled remote socket branch; otherwise the result is session not found. src/main/triggers/trigger-watcher.js:1182, :1330, :1352, :1375 v1 must branch before legacy required-command validation and before remote delivery. Legacy delivery is broader than direct map lookup, but it does not resume a closed session.
    Direct/historical tab lookup is normalized; descriptor fallback can identify a local tab even when the original map key differs. src/main/triggers/trigger-context.js:43, :62, :92 Preserve existing proven open-tab delivery; do not equate a merged UI row with an exact session ID.
    A normalized-ID FIFO includes historical-ID equivalence, then delivery takes a mutex on the pinned tab. FIFO wait currently precedes the delivery budget. src/main/triggers/trigger-watcher.js:1190, :1392, :2065; .ai/contexts/trigger-watcher.md:201 Hold the same locks across the entire v1 action. Do not recursively acquire them by feeding another file to the watcher. v1's bounded FIFO rule is specified below; legacy timing stays unchanged.
    Pinning captures the session object, generation, PTY and available process identity. Each write rechecks ownership; unrelated writers refuse. src/main/triggers/trigger-context.js:111, :127, :210, :247, :324 Reuse pinned delivery for prompts and pin the stop target too. A replacement tab must not receive a queued prompt or stop.
    Results use processed/<basename>.result.json, write a temporary file, rename with retry, and unlink the trigger. Result defaults include submitted, 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:2091 Preserve the legacy result contract; give v1 the publication and interruption rules in §8. Do not claim HEAD has durable deduplication.
    The watcher installs fs.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, :2160 Reuse watch/scan/backpressure. Add v1 recovery without replaying a claimed action.
    Chains share a global deadline, call submitWithVerify, and wait for busy fall only for non-final steps. A no_rise completion is possible. src/main/triggers/trigger-watcher.js:1664, :1853, :1965, :882, :926 stop.before needs an internal final-turn wait with positive evidence, not an ordinary one-command chain or a dummy second command.
    openSession deduplicates renderer openings. openSessionNow can show an existing entry, resolve continuations, restore/attach background jobs, run the resume guard, create a terminal and invoke open-terminal. Successful opens can return undefined. public/app.js:2465, :2493, :2516, :2522, :2535, :2606, :2617, :2630, :2685 Use the wrapper and this lifecycle, but add an explicit exact-ID, noninteractive trigger mode and a structured acknowledgement. Never interpret undefined as successful opening.
    The guard refuses a live session automatically when automatic:true and allowBgAttach: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, :70 Preserve manual/restore behavior. Use the automatic refusal branch, plus the checked main-side liveness gate below. No confirmation or attach is allowed for v1.
    Row-open lookup prefers the row head and merged members; continuation resolution can choose another part. public/session-activity-dom.js:119, :127; public/resume-guard.js:115, :157; public/sidebar.js:745 A closed-session trigger must not silently resume a head, saved continuation choice, or sibling instead of the requested ID.
    launchNewSession allocates an ID, adds a pending sidebar row and terminal, calls open-terminal with isNew:true, persists and returns the ID or null. public/app.js:2320, :2336, :2353, :2362, :2376 Extend this function with internal trigger flags; do not create a parallel UI lifecycle.
    + → Claude resolves defaults before calling launchNewSession. 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:1776 Reuse resolveDefaultSessionOptions; do not add prompt-derived CLI arguments or partially reconstruct settings.
    Sandboxed CLI descriptors use namespace-local PIDs; the host status cache and writer reader test those numbers as host PIDs, and delivery identity checks host /proc. scripts/claude-sandbox.sh:848; docs/sandbox.md:411; cli-session-state.js:520, :889, :908; main.js:4014 Add the v1 raw-descriptor liveness check, including without a local PTY, and the PTY-scoped namespace translation in §7 before host liveness/identity filtering; a fresh descriptor alone cannot prove startup readiness.
    Sandbox launch forwards added directories, and the wrapper binds project/extra directories writable while protecting .claude and .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:934 Share the watcher directory resolver with sandbox launchers and protect the control tree at every visible mount alias, with ancestor pins and fail-closed checks.
    Historical trigger lookup can resolve requested A to the same session now keyed B. Re-key removes A from the main map; open-terminal reattaches only by its exact key. src/main/triggers/trigger-context.js:62, :92; session-transitions.js:631; main.js:2833, :2871 Keep requested ID, current tab key and observed writer identity separate in renderer RPC and pinned delivery.
    submitWithVerify can return timedOut:true after Enter; the ordinary single-command caller does not map that flag to failure. src/main/triggers/trigger-watcher.js:774, :1608, :1645 Map v1 verification timeout explicitly before deciding open success; keep legacy behavior.
    Archived groups remain in the archive-inclusive list and retain the local + action. Settings-hidden groups are excluded even from that list. archived-projects.js:68, :113; session-cache.js:1076; public/sidebar.js:1304, :1740; public/dialogs.js:97 Admit known archived groups with ordinary + defaults; carry hidden/removed settings provenance separately and check it before membership.
    Main's open-terminal reattaches 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, :3069 A v1 permit must exclude remote/attach/plain-terminal paths and forbid the missing-transcript fresh-start fallback. Recheck before spawn, after awaited work.
    Existence alone does not prove a readable transcript or cwd. resolveSessionRealCwd uses verified transcript cwd; sessionTranscriptExists only checks existence. Cached rows can locate transcripts. derive-project-path.js:254, :271; session-index-store.js:626, :631; read-session-file.js:20 Add a strict local resume-target resolver, not just an existence test.
    Checked liveness checks tracked schedules, but drops raw descriptors whose PID is absent or has a different start identity on the host, without marking the scan unknown. The ordinary IPC uses the unchecked helper. cli-session-state.js:66, :799, :818, :1111, :1120; main.js:3559, :3587; public/resume-guard.js:53 Add liveElsewhereV1Checked as specified in §7, including schedule/background checks and namespace resolution without a local PTY. Existing liveElsewhereChecked alone cannot authorize v1 resume.
    Sidebar Stop plans targets, confirms, executes the local stop IPC, and updates UI. stop-session sets stopRequested, kills a normal PTY, or detaches an attach tab. public/app.js:1392; public/stop-session-ui.js:16, :297; main.js:1964 Share the local normal-PTY stop backend. Do not call the interactive confirmation or row-wide planner from a file trigger. Do not treat detach as stop.
    Stop IPC returns after requesting termination; process exit marks session.exited and sends process-exited with the generation. Windows termination can be asynchronous. main.js:1974, :2700, :2718; src/main/terminals/pty-ops.js:108 Report closed only after the pinned entry exits; a kill request alone is insufficient.
    Renderer terminals are real xterm entries registered in openSessions. showSession changes selected state and focuses the terminal. public/terminal-manager.js:1097, :1239, :1266, :1510, :1572 For trigger opens, retain creation, sizing, output and persistence but skip selection/focus; add a grid card without focusGridCard.
    Main already requests renderer work with 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, :121 Use a named IPC request/reply broker; do not use injected JavaScript or a new external protocol.
    Startup loads projects and restores active/working-set sessions asynchronously. A loaded BrowserWindow alone is not application readiness. Closing all windows quits on non-macOS; macOS can remain running without a window. public/app.js:2935, :2950, :2973; main.js:450, :4068, :4074 Add an explicit renderer-ready handshake; do not rely on did-finish-load, and do not create a window for a trigger.
    The documented token bucket is for the remote send adapter, not local creation. .ai/contexts/trigger-watcher.md:364; src/main/triggers/trigger-watcher.js:2040 v1 needs its own small fixed new-session limiter. Do not reuse remote-send policy or describe eight in-flight files as a session cap.

    3. Wire format

    Publish UTF-8 JSON at <triggersDir>/<trigger-uuid>.json, preferably by renaming
    a 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 the
    request, not the target session.
    Results remain at processed/<trigger-uuid>.result.json. Never reuse a v1
    request 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"}

    prompt is optional only for resume. Omission means open/ensure an open
    session without typing. An empty or null prompt is invalid, not omission.
    before is optional; it is one prompt string, not an object or public chain.
    new.project, new.name and new-session prompt are required. timeout_ms
    is optional everywhere. expectedCwd is optional only with sessionId and
    uses 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, reason as specified and submitted:"no"
    unless a later execution step actually wrote bytes. Keep dynamic OS/parser
    messages in error/detail, not in a new stable refusal code.

    Check Exact reason
    Valid JSON is not a non-null, non-array object trigger must be an object
    Either version or action exists, but version !== 1 (no coercion) unsupported version
    Versioned request action is not exactly open or stop unsupported action
    v1 filename stem is not a lowercase UUID trigger filename must be a lowercase UUID
    An open request has both/neither own properties sessionId and new open requires exactly one of sessionId or new
    A stop request has no own sessionId missing required field: sessionId
    Disallowed property at any level unsupported field: <path>
    sessionId is not a string matching the UUID rule sessionId must be a UUID
    new is not a non-null, non-array object new must be an object
    new.project is absent, not a string, empty, or not an absolute native path new.project must be an absolute path
    new.name is absent, not a string, or whitespace-only new.name must be a non-empty string
    new.name.length > 256 new.name too long (max 256 characters)
    new.name contains U+0000–U+001F or U+007F new.name contains control characters
    Required/present prompt or present before is not a string, or is whitespace-only <field> must be a non-empty string
    Its JS string length exceeds 4096 <field> too long (max 4 KB)
    It contains CR, LF, NUL or ESC <field> contains forbidden control characters (\r \n \0 \x1b)
    Invalid explicit timeout (not integer 1–600000) invalid timeout_ms
    Invalid expectedCwd expectedCwd must be a non-empty string when present

    <field> is literally replaced with prompt or before; <path> is the
    lexicographically first disallowed dotted field, for example cli, wait,
    new.options or new.permissionMode. For nested object errors, check object
    type 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 new or prompt; resume cannot carry before;
    new cannot carry sessionId or expectedCwd. Reject command, key, chain,
    cli, options, focus and 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:..., bridge
    IDs, whitespace and paths. Request filenames use the same pattern without the
    i flag, so their input/result/marker paths have one canonical spelling. Prompts are not
    trimmed 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 version nor action present means legacy dispatch, with its current
    command/chain/key fields, permissive session identifiers, defaults, results,
    remote behavior and timing. A lone action is 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" and
    HEAD's complete dynamic reason at src/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}
    Field Meaning
    version, action Requested version/action, with null for a missing value. Unsupported supplied values are echoed without coercion.
    outcome Exactly opened, closed, refused, or not-found.
    ok True only for opened or closed after the requested action, including required delivery, succeeds.
    reason Null for success; an exact specified refusal/not-found string for failure.
    sessionId Canonical requested ID for resume/stop, or main-reserved new ID; null before a valid ID has been resolved/allocated. An already-open historical/descriptor match retains the requested ID even when its current tab key or writer ID differs. Never a name.
    resumed Present for open-resume only. True after this request actually spawns a resume; false for an already-open/reattached session or an unconfirmed spawn.
    created Present for open-new only. True after this request actually spawns the new session; false before that.
    submitted, wrote Preserve delivery semantics: no, assumed, activity, confirmed; wrote is delivery's write-attempt/partial-write evidence, not proof a turn completed. Opening or killing a PTY does not count as prompt writing.
    steps_total Zero without a prompt/before, otherwise one. No synthetic stop command is counted.
    Delivery evidence Retain sent_at, wait timings, retries, partial text/Enter evidence, confirmation, compaction and step evidence from the shared delivery function when applicable.
    error, detail Existing delivery/backend diagnostics. reason remains the stable lifecycle contract.
    uncertain True only when an interrupted/timed-out action may already have taken effect and its completion is unknown. This forbids an automatic retry.

    Open'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:true is never an open success. If verification times out
    after 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:1 and all submission/verification evidence. With no observed
    reaction, submitted remains "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 true resumed/created evidence, and
    leave the ordinary session open. There is no rollback kill. A caller must not
    interpret refused as proof that no process or prompt exists. An uncertain
    result likewise makes a false resumed/created mean “not confirmed,” not
    “definitely did not happen.” On restart, emit true flags only if the action
    marker durably records that evidence.

    closed means the pinned PTY exited, not merely that stop IPC returned. A
    timeout after kill was requested is refused, reason:"session stop not confirmed",
    uncertain:true; termination already initiated may still finish later. This
    differs 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.

    Case Required behavior / exact reason on failure
    Open here, normal local session Deliver optional prompt through existing pinned delivery with forced idle waiting. No spawn, selection change or rename. opened, resumed:false. No transcript requirement for this case, including an ordinary just-created session.
    Main owns normal PTY but renderer entry is missing after reload After ready, reattach it using the renderer lifecycle with reattachOnly:true; never spawn. Then deliver as above, resumed:false.
    Closed, readable local transcript with verified cwd and known project Resolve exact requested ID, use the guarded renderer lifecycle, spawn once in recorded cwd, then wait and deliver. resumed:true only after spawn.
    Live in another process refused, session live elsewhere. No prompt, duplication confirmation, fork or attach.
    Backgrounded, background handoff in progress, or background attach tab refused, background session is not supported. This includes backgrounding, backgrounded, isAttach and a live kind:"bg" descriptor. Legacy attach-tab delivery is unchanged.
    Live scheduled run 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.
    Remote tab, remote cache row or descriptor-only remote target 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.
    Plain terminal / panel shell refused, plain terminal sessions are not supported.
    Known closed ID, missing/empty/unreadable/non-regular/unusable transcript refused, no readable transcript. Never start fresh under that ID.
    Valid UUID absent from local open state, local cache/transcripts and live/remote evidence not-found, session not found. No placeholder is created.
    Non-UUID ID, including a human name Schema refusal sessionId must be a UUID.
    Resume resolves more than one local transcript for the same normalized ID refused, ambiguous session transcript. Do not select the first folder.
    Recorded cwd cannot be verified, or valid project has no resolvable recorded cwd refused, session cwd unavailable. Unreadable/unusable JSONL itself uses no readable transcript.
    Project path does not match a known local project refused, unknown project. Do not add/import/create a project. Applies to new and closed resume; existing open-tab delivery remains available.
    New or closed-resume project has an exact local entry in global.hiddenProjects, with hiddenProjectKinds equal to archive, delete, or absent refused, project is archived. This check precedes known-project membership and directory existence, even when the project is absent from allProjects. Do not unhide or restore it. Existing open-tab delivery remains available.
    Project is in local allProjects and has only an archivedProjects group record Eligible for new or closed resume, subject to the other checks. New uses the same saved defaults and ordinary launch/archive effects as + → Claude; archive-list visibility does not gate admission.
    Recorded cwd/new project directory no longer exists refused, project directory does not exist. Do not fall back from a missing worktree cwd to its parent.
    Project snapshot/checked live-state lookup fails or is unknown, including an unresolved descriptor namespace identity refused, respectively project lookup unavailable or session liveness unavailable. Do not treat exceptions as absence.
    expectedCwd is unknown/mismatched Keep the exact existing reasons: this 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.
    New request passes validation/project checks Reserve a fresh ID in main, use renderer resolveDefaultSessionOptions(project) and launchNewSession, save the display name through the existing writer, wait, deliver required prompt. Return that ID with created:true.
    New-session bucket empty refused, new session rate limit exceeded. No renderer placeholder or PTY; no waiting for refill.
    Two different trigger UUIDs target one closed session Same normalized session FIFO as legacy. The first performs the resume and its delivery; the second re-resolves after acquiring the lock, sees the open session and only delivers. First resumed:true, second false if the first remains open. Prompts remain FIFO.
    The same trigger UUID appears repeatedly One terminal result, one lifecycle action. Return/reuse existing result, clean up the duplicate input; never overwrite the result or replay the action.
    Window exists but renderer is not ready Wait within the action deadline; no request is sent before the ready handshake. On expiry: refused, renderer not ready.
    Window is absent/destroyed at admission 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.
    Renderer disappears/reloads after dispatch Revoke its permit, return refused, renderer unavailable, preserving known effects and uncertain:true if necessary. Do not redispatch in the next document.
    Renderer acknowledged ready but never acknowledges an action refused, renderer action timed out; revoke the permit at the deadline. Preserve already-created visible session and evidence.
    Open prompt verification reaches the action deadline after Enter refused, session action timed out, uncertain:true; retain submission/write and lifecycle evidence as in §4. No late recovery Enter or rollback kill.
    Application is closing refused, Switchboard is closing.; no new launch, write or stop after admission is revoked.
    Stop finds no eligible open local target If known closed: not-found, session not open here; if wholly unknown: not-found, session not found. Never resume to stop or deliver a note.
    Stop without before, target is busy Immediate refused, session busy; leave running and write nothing. Test both _cliBusy and readable descriptor busy.
    Stop without before, descriptor is waiting/shell/unknown refused, respectively session waiting on a dialog, session running a shell command, session state unavailable. Check these states after busy and before composer eligibility on every sample.
    Stop without before, idle target with pending or unobservable composer Immediate refused, respectively session has unsubmitted input or session composer unavailable; no waiting, writes or kill.
    Stop without before, idle target with empty composer inside the quiet window Wait only for the remaining quiet interval within the same action deadline, rechecking state/ownership/composer. Stop at the first eligible sample with now - lastInputAt >= quietMs and now < deadline. If still waiting when the deadline is reached, refused, session input not quiet; no deferred stop. §7 defines recheck and boundary behavior.
    Stop with before, target initially busy Wait for idle, deliver before politely, verify its submission, await its completed turn, then revalidate and stop that same pinned target.
    Stop with before cannot finish readiness, delivery or turn wait by deadline 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.
    New activity starts after note completion or before bare stop Recheck immediately before stopping. refused, session busy if another turn has started; do not kill it. Other state/ownership failures retain their refusal.
    PTY/generation/ownership changes during an action Refuse using the existing pinned-delivery reason; never retarget. For stop, use session target changed when the pinned stop object/generation is replaced.
    Backend cannot open / save name / initiate stop refused, respectively session open failed, session name could not be saved, session stop failed; retain backend error and any known effects.

    Known-project definition: a local project in the current main-side projects
    query's allProjects set after normal project assembly. An archivedProjects
    group record alone does not disqualify it: showArchived:true retains these
    groups (archived-projects.js:68) and their local + action remains available
    (public/sidebar.js:1304, :1740; public/dialogs.js:97). Use the same project
    snapshot service as
    get-projects('both') (main.js:1339, :1358, :1364, :1382), extracted into
    a 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:3 policy. Do not use a string-prefix
    containment 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.hiddenProjects and global.hiddenProjectKinds provenance before
    filtering: session-cache.js:1076 and archived-projects.js:113 remove those
    paths even from allProjects. After obtaining a valid snapshot, check an exact
    normalized local hiddenProjects entry first, then local allProjects
    membership, then directory existence. Both kinds archive and delete, and
    legacy entries without a kind, return project is archived; this stable v1
    reason covers settings-hidden/removed projects. It takes precedence over
    unknown project and project directory does not exist, including when both
    hidden and archivedProjects records exist. A kind entry without matching
    hiddenProjects membership is not a denial. Remote-qualified settings entries
    do not deny a local path. A hidden repository ancestor alone does not deny a
    distinct worktree project that remains in allProjects with its own + action.
    If the query or its settings provenance cannot be read consistently, use
    project lookup unavailable, never absence. This does not change existing
    open-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_DIR project folders, without descending into subagents or
    remote mirrors. This permits a readable transcript awaiting cache refresh.
    Use bounded verified-cwd parsing (extractVerifiedCwdFromJsonl) and map worktree
    cwd to its known project root with resolveWorktreePath; keep the recorded
    cwd 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 unknown
    ID, 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 guardResume as an additional renderer check with automatic:true, allowBgAttach:false and
    onBlocked. A liveness refusal never offers “Resume anyway.” This prevents
    known 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.js for v1 shape validation, target resolution,
    action orchestration and the limiter. Add
    src/main/triggers/trigger-renderer-bridge.js for bounded renderer RPC and
    private 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 validation
    and 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 reattach simply acknowledges an existing renderer entry, or
    reattaches 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:

    Identity Authority and use
    requestedSessionId Immutable normalized caller ID for resume/stop; null for new. It remains the request/result identity and the input to existing historical/descriptor ownership resolution.
    tabKey Exact current main-map key of the resolved entry and the key used by renderer openSessions and open-terminal. For an existing target, derive it from the map entry holding the proven session object, not from the requested ID or session.realSessionId. Closed resume initially uses the requested ID; new uses the main-reserved ID.
    Writer identity Main-only pinned delivery evidence: observed writer session ID (binding.effectiveId/readId()), process identity/start, session object, PTY and generation. It is checked through createTriggerContext and 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 renderer
    checks or recreates its ordinary entry under B; a missing renderer entry invokes
    openTerminal(B, ..., {reattachOnly:true}, ..., permit). It must never try A or
    spawn another PTY. Main validates the ACK against (requestId, documentEpoch, mode, requestedSessionId, tabKey, generation) and the same live session object
    and 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 key
    or 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 first
    side 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:1964 and remains the backend
    of 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=1
    exposes the Podman socket (scripts/claude-sandbox.sh:267–270), the service can
    start a container with host paths mounted writable, including the effective
    triggers directory, processed/ results and action markers. The read-only
    mounts 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 a
    launch 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 nonempty
    SWITCHBOARD_TRIGGERS_DIR override or ~/.switchboard/triggers, resolved to an
    absolute path in the main process. Share/export this resolver rather than
    duplicating a default in main.js. Main passes that exact path as the internal
    single-path environment value SWITCHBOARD_SANDBOX_TRIGGERS_DIR to every
    claude-sandbox.sh invocation: normal new/resume/fork and scheduled launches
    (main.js:3157, :3936). It is not a trigger field or a colon-separated extra
    bind. 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 effective
    root and processed/, including a separately resolved processed symlink
    target. 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 or
    deeper 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 the
    CLI; 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:

    Direction API/channel Payload / responsibility
    Renderer → main window.api.triggerActionsReady() / trigger-actions-ready (invoke) Called after handler registration, successful initial project load and startup restore/reattach. Main binds readiness to this WebContents and main frame/document generation.
    Main → renderer window.api.onTriggerSessionAction(cb) / trigger-session-action `{requestId, documentEpoch, mode:"resume"
    Renderer → main window.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.
    Renderer → main, existing invoke extended internally openTerminal(..., initialSize, triggerRequestId) / open-terminal Optional last argument identifies a pending, single-use main-issued permit. Ordinary callers keep their current five arguments.

    The 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 authorize
    another 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-terminal path rechecks deadline/closing state,
    current target, local-only eligibility, liveElsewhereV1Checked, exact
    transcript and recorded cwd after awaited preparation and before actual spawnPty. Prevent
    concurrent 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 startsFresh recovery for ordinary UI callers. A resume permit must
    select --resume <exact id> or return a refusal; it must never select
    --session-id, a fork or a remote/attach branch. New permits use the reserved
    new 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 handleTriggerSessionAction in public/app.js and register
    the 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 → openSessionNow with automatic:true,
    activate:false, continuationResolved:true, allowBgAttach:false and the
    private permit. Construct an exact-ID session view with sessionId and
    headSessionId both equal to the requested ID and mergedMemberIds:[].
    In the trigger mode, bypass the dormant background-restore override at
    public/app.js:2522; refuse its background provenance instead. Do not run the
    continuation chooser or remembered choice. Keep the resume guard and the
    normal creation, size sync, MCP/sandbox state, persistence and polling paths.
    For reattach, address tabKey through the ordinary ensure-open lifecycle and
    the reattachOnly path above; do not construct a closed-resume view for the
    historical requested ID. Return an explicit status containing the correlated
    requested ID, tab key and generation, including existing-entry/reattach success.
    false, null and undefined are 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 use
    resolveDefaultSessionOptions(project) and the normal pending/sidebar/terminal
    path. Persist the name through the existing session writer, targeting the new
    ID only (main.js:2271), before delivering the prompt; reflect it in pending
    renderer 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; preserve
    the 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.

  2. jbr-sekoia commented on Nov 5, 2026

    @jbr-sekoia
    CollaboratorAuthor

    Part 2/2

    7. Readiness, prompt delivery and final note

    Do not use a spawn ACK, empty composer or _cliBusy === false alone as proof
    that 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:

    1. waitForIdle (trigger-watcher.js:986) for the busy-flag initial wait.
    2. waitForComposerFree (:374): pending input must be zero and the existing
      quiet window must pass (default 3000 ms). Missing composer state remains busy.
    3. waitForCliIdleAfter (:655), using getBusyFallSettleMs (default 300 ms),
      for descriptor readiness and dialog/shell/busy refusal. Keep the existing
      last-moment liveness, ownership and deadline checks (:1544, :1587).
    4. submitWithVerify (:740): text plus discrete Enter, current composer check,
      busy observation and at most its existing recovery Enter. Preserve the
      evidence classifications and the /compact confirmation rule.
    5. For stop's final note, waitForBusyFall (:882) with the actual note Enter
      timestamp and the existing transcript/descriptor completion evidence.
      Reuse waitForPendingConfirmation (:486) and the transcript own-entry
      evidence when needed; do not create a second PTY sender.

    Force wait:"idle" internally for v1 prompts and before. Open-here keeps the
    existing 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 obtains
    settled idle before typing. Reuse waitForCliIdleAfter with this lower bound
    (adjust the strict > boundary to include equality without changing legacy
    semantics). If it returns available:false, keep polling within the same
    deadline for this startup mode; do not accept the legacy immediate fallback.
    Record launchStartedAt in main immediately before the actual spawn, not at
    renderer 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 need
    this prompt-readiness gate: opening alone is the requested operation.

    V1 liveness before resume

    Add liveElsewhereV1Checked in cli-session-state.js as a v1-only checked
    main-side entry point. Its result is {known:true, live} for a proven live
    writer, {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; calling
    that 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 from getStatus,
    readAllDescriptors, the filtered writer snapshot or the existing checked
    helper'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 comparable procStart to
    the 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.pid and 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 live
    background 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 to
    refused, session liveness unavailable. This includes zero/multiple host
    matches, 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/readWriterDescriptors discard a descriptor if that
    number is not live on the host (cli-session-state.js:520, :889), and
    deliveryProcessIdentity compares procStart at the same host PID
    (:908). Main supplies getStatus(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 createTriggerContext for sandbox targets and backed by the shared
    raw 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 launchStartedAt for the permitted launch.
    For each candidate raw descriptor, prove one unique host writer as follows:

    1. Read host /proc/<hostPid>/status (NSpid), stat (parent and start
      time), and ns/pid (namespace identity). Its innermost NSpid must equal
      raw pid, and its host start time must equal the descriptor's comparable
      Linux procStart. The writer must be in a different PID namespace from
      the 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.
    2. Prove the live host ancestry to the pinned host PTY using parent/start
      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.
    3. Re-read descriptor identity and host process/namespace/ancestry evidence to
      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 session
      ID 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, waitingFor and proven writer
    from that same observation. Use this source for the pinned v1 context's
    getCliStatus and 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 existing
    host-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/quiet
    composer 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 evidence
    and leaving the session open. During delivery, loss of proof suppresses further
    bytes under the pinned ownership refusal; stop.before retains
    note step not finished with diagnostics and no kill. Bare stop without
    usable 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 delivery
    failures lacking reason use their exact error as the v1 reason. This
    mapping 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 inherit
    that 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 submitWithVerify return 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 out refusal with
    uncertain:true and the actual submission classification; preserve
    timedOut:true, Enter timestamp, retries, wait timings, partial/write and
    lifecycle 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:true if Enter may have taken effect and the underlying timeout
    evidence under delivery. In both cases, settle once, cancel pending polls and
    recovery/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.

    before is an internal one-step delivery with a waitForFinalTurn mode.
    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. Continue
    waiting 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_rise return into permission to kill. The same global deadline
    covers initial busy waiting and this final wait. On failure/timeout, publish
    note step not finished, retain underlying evidence under delivery, cancel
    the 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: noteUserInput preserves the last
    keystroke time after deletion (src/main/terminals/composer-state.js:284),
    and waitForComposerFree checks both conditions
    (src/main/triggers/trigger-watcher.js:385). Use the existing getQuietMs()
    configuration (default 3000 ms) and lastInputAt || 0 convention.

    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 immediately
    with session has unsubmitted input. An observable empty composer with
    recent 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 + quietMs satisfies silence only while
    now < deadline. If the deadline arrives while waiting, including exactly at
    the 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.json before sending renderer work, delivering
    text 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 saved and take no action. Update confirmed phase
    after 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 ID
    and 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

    File Work
    src/main/triggers/trigger-watcher.js Versioned dispatch, shared callable delivery, session/tab lock reuse, v1 absolute deadlines and explicit verification-timeout mapping, final-note wait, one publication, marker recovery. Share/export the effective triggers-directory resolver with sandbox launchers. Preserve legacy paths.
    src/main/triggers/trigger-actions.js (new) Schema/reasons, strict local resolver, case orchestration, limiter; injected dependencies for tests.
    src/main/triggers/trigger-renderer-bridge.js (new) Ready handshake, document-scoped requests, single-use permits, cancellation, requested-ID/tab-key/generation correlation.
    src/main/triggers/trigger-context.js Inject lifecycle dependencies alongside existing delivery helpers; expose the exact current map key for a proven entry and retain separate pinned writer identity and guards. Route v1 sandbox status and ownership reads through the same PTY/generation-scoped proof, without direct-key fallback on missing proof.
    cli-session-state.js Add the shared v1 raw descriptor reader, liveElsewhereV1Checked without 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.js Wire modules, share project snapshot/stop helpers with hidden/removed settings provenance, token-bearing open validation and pending-open deduplication, final pre-spawn rechecks; record the actual sandbox launch and PTY/start/generation evidence for the scoped reader. Pass the shared effective triggers path to all sandbox launch sites.
    scripts/claude-sandbox.sh, test/sandbox-wrapper.test.js Protect the effective control tree at all visible mount aliases, pin ancestors, reject unsafe/missing paths and writable overlaps; extend existing fake-bwrap and isolated real-bwrap coverage.
    docs/sandbox.md Effective triggers-directory protection, unsafe-layout refusal, sandbox-launch coverage and the fresh-launch boundary; explicitly state that protection of triggers, results and markers does not hold when the Podman socket is exposed, preserving the existing opt-in and launch behavior; v1 raw-descriptor liveness before resume without a local PTY, PTY-scoped PID translation and refusals when liveness/readiness proof is unavailable, preserving the documented limitations of other status consumers.
    preload.js Named callbacks/invokes only, optional internal open-terminal argument, listener cleanup.
    public/app.js Trigger handler, exact-ID mode, structured open acknowledgements, internal new flags, readiness signalling, nonactivating sidebar/card lifecycle.
    public/dialogs.js, public/terminal-manager.js, public/grid-view.js Reuse existing defaults and lifecycle/card helpers; change only as needed for nonactivation. Preserve manual +/click behavior.
    .ai/contexts/trigger-watcher.md Public v1 formats/reasons/results, modes and guards, focus, rate limit, deadlines, final-note evidence, recovery and caller uncertainty; explicitly state the control-directory protection limit when the Podman socket is exposed and the unchanged Podman opt-in/launch behavior.
    .ai/contexts/ipc-bridge.md, .ai/contexts/cli-session-state.md New broker channels and checked noninteractive resume behavior where those interfaces are documented; raw/host writer identities, sandbox proof and its v1-only scope.
    docs/automation.md, docs/e2e.md Caller contract/examples and any isolated IPC journey.
    CHANGELOG.md ## Unreleased → ### New, entry below.

    Changelog entry:

    File triggers can reopen a closed session, start a named session in a known project, or stop an open session after an optional final prompt. Opened sessions appear in the sidebar without taking focus. (#1022)

    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, openSessionNow and
    launchNewSession as needed with loadAppFunctions from test/app-source.js:99
    and setupSidebarDom. Do not copy implementations into tests. Follow
    test/open-session-once.test.js:60; disable installed spies when declarations
    would overwrite them. Assert both effects and forbidden calls.

    Test file / executable criterion Observable failure condition
    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. Invalid input launches, emits text, or yields a different reason; valid boundary rejected.
    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. Prompt precedes renderer ACK/readiness, existing target is spawned, or no-prompt case types anything.
    Same file plus test/dom-trigger-actions.test.js and test/trigger-renderer-bridge.test.js: use real createTriggerContext, 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, result sessionId:A, resumed:false, and zero spawns. Remove only renderer B after readiness, repeat with another request UUID, and assert openTerminal receives B with reattachOnly:true and re-creates B without spawning. Also cover descriptor-proven writer A in a tab keyed B. ACK rejected solely because A differs from B; an A entry or PTY is created, renderer reattach addresses A, result silently changes to B, or a writer ID is treated as the map key.
    Same files: hold the A→B ACK; send an ACK naming A as the key or the wrong generation, then replace/remove the main B entry or its PTY. Resume the deferred reply and advance past the deadline. Invalid ACK permits delivery, a replacement entry receives bytes, lost B falls back to spawning A, or late ACK completes a settled request.
    Same open test file: two UUID-named requests target one closed ID, including uppercase/lowercase and known historical aliases. Hold first opening and submit verification with deferred promises. More than one resume while first remains open; overlapping writes; second prompt precedes first; unrelated tab waits on this target.
    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. A fresh-start fallback, wrong transcript/cwd selection, unintended project import, or mismatch in the outcome/reason table.
    Same file plus test/dom-trigger-actions.test.js: a local project with only an archivedProjects record remains in allProjects. 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 archived refusal for a known group archive, different defaults or extra archive-setting mutation compared with +.
    Same target file: separate hiddenProjects fixtures with kinds archive, delete and absent, each absent from assembled allProjects but retained in snapshot provenance. Exercise new and closed resume, with both existing and missing project directories and an overlapping group archive. Each returns refused, 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. Settings-hidden/removed path becomes unknown project/missing-directory or launches; a nonhidden known project is denied; missing provenance is treated as absence; wrong stable reason.
    Same file: liveness fixtures for external process, live schedule without descriptor, unattached bg, attached/backgrounding tab, remote tab/cache/descriptor with remoteTriggers on/off, plain terminal and unreadable liveness. Any spawn/attach, remote adapter call, prompt or stop occurs on a refused target.
    Same target file plus test/cli-session-state.test.js: initialize the real descriptor module on a disposable sessions directory and call the actual v1 target resolver and liveElsewhereV1Checked, 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 return refused, session live elsewhere, with zero renderer bridge calls, zero spawns and zero bytes. Also cover an unrelated local tab and namespace/ID collisions. Host N filters out the raw candidate, the resolver treats it as known absence, no-prompt opening bypasses liveness, an unrelated tab excludes H, or any bridge call/spawn/write occurs. A stubbed live result does not satisfy this criterion.
    Same files and real-reader path: for each host-N variant above, make H's namespace/start mapping unavailable, ambiguous, unreadable, incomplete or changed during revalidation; include zero host matches with a matching raw descriptor, unsupported identity and scan deadline. Both prompt modes refuse session liveness unavailable with 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. Unresolved raw identity becomes {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. Continuation chooser/confirmation/attach called, wrong ID launched, or trigger options override saved settings.
    Same file: new reserves supplied internal ID, displays/persists given name, uses saved permissions/sandbox/worktree defaults; resume/open failures cannot ACK success. Capture sidebar/terminal/persistence calls. Missing ordinary entry/persistence, failed spawn reported opened, name failure loses created-ID evidence.
    Same file: selected session, active view, modal and active DOM element remain unchanged in single/grid; native focus API and showSession/focusGridCard spies remain untouched. Manual + and click still select normally. Any focus or selection change for a trigger; normal UI path loses activation.
    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. An unavailable/old descriptor, empty composer, old idle stamp or short idle flicker authorizes startup text.
    Same readiness file plus test/cli-session-state.test.js: initialize the real descriptor module on a disposable sessions directory, use real createTriggerContext and 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, matched procStart, 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. A test replaces getCliStatus/observeTabWriter with a fabricated ready result, bypasses raw parsing/filtering, times out despite valid H, or uses N's host liveness as writer proof.
    Same files: keep the raw idle descriptor fresh but independently remove/mismatch 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. Any unproven or replacement writer receives bytes/kill; lost proof preserves idle-settle credit; startup does not refuse session readiness unavailable with retained launch/ID evidence; late proof changes a terminal result; global/legacy readers acquire translated v1 state.
    Isolated Linux real-bwrap fixture in test/trigger-actions-readiness.test.js: reuse the sandbox rig/usable-userns gate from test/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/stat start identity and controlled busy/idle transitions. Confirm one prompt reaches only that PTY after settled fresh idle, with opened and correct created/resumed flags. A second run publishes a mismatched start identity and must expire with session readiness unavailable and no bytes. Tear down only fixture processes. Linux CI requires SWITCHBOARD_REQUIRE_REAL_BWRAP=1 using the same mandatory gate as test/sandbox-wrapper.test.js:1535. Host/inner PIDs are equal, the test skips on the required CI runner, fake argv checks replace namespace observation, mapping cannot deliver a valid prompt, bad identity authorizes text, a real CLI/user directory is accessed, or the fixture drops PID isolation.
    Same file: action deadline expires between text and Enter/recovery. Include dirty/missing composer, dialog, exit, ownership/generation change and compaction evidence. Late Enter/recovery/cleanup, false confirmed status, or lost partial evidence.
    Same readiness file: real shared submitWithVerify writes 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, expecting note step not finished and 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. Busy termination, detach, sibling stop, or closed result before exit.
    Same stop file with injected clock and the real composer reducer: type then delete the last character at t, leaving pending:0 and lastInputAt: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, and closed only 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. Stop precedes silence, equality is rejected within budget, deadline equality permits a kill, wrong terminal reason/evidence, or a cancelled wait kills later.
    Same file: remove composer observation at admission and during the quiet wait: immediate session composer unavailable. Add pending text: immediate session has unsubmitted input. Another type/delete while waiting resets lastInputAt and 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. Empty is treated as quiet, unobservable state is treated as empty or waited out, stale composer permission survives the final guard, wrong state precedence/reason, or the deadline is extended.
    Same file: before on busy target waits; note is one delivery; held busy-fall prevents stop; release completed turn then stop. No-rise/absorbed Enter, unconfirmed submit, dialog, expiry, composer interference and new turn after completion prevent kill. Advance clock past result to prove no delayed kill. Last-step ACK/no_rise triggers stop, timeout is followed by kill, or turn completion is ignored.
    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. Wrong token boundary, renderer placeholder on rate refusal or applying the limit to open-session count.
    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. Lost request starts late, no-window creates a window, fake reply completes action, a stale permit spawns, or same ID spawns twice.
    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. Any interrupted action is replayed, existing result overwritten, result published partially, lost-input marker ignored, or publication failure deletes the only recovery state.
    Extend test/session-launch-guards.test.js with executable extracted/injected permit guard tests and retain current ordinary fresh-fallback tests. Token-bearing resume with disappeared transcript selects --session-id; ordinary retry stops working.
    Extend test/sandbox-wrapper.test.js without exposing the Podman socket, using makeRig({recordArgs:true}), parseMounts, accessAt and mountAt (: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 and processed resolved 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. Only the default path or one alias is protected, a later bind reopens the tree, an ancestor can be replaced, missing-path protection is skipped, or a rejected layout launches the CLI.
    Same file, without exposing the Podman socket, isolated real-bwrap mode using 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 requires SWITCHBOARD_REQUIRE_REAL_BWRAP=1 so this proof cannot silently skip (:1535). A sandbox write reaches the host control tree, any replacement succeeds, host publication fails, or ordinary allowed writes stop working. Fake argv checks alone do not prove these filesystem effects.
    Extend launch-boundary unit coverage with extracted/injected main spawn helpers: normal/manual and trigger new/resume/fork plus scheduled sandbox launches all receive the same absolute effective path as the watcher, even with a relative override and a different spawn cwd. A launch site omits the path, reconstructs the default, or protects a different directory; unsafe sandbox failure causes an unsandboxed retry.
    Run relevant existing trigger/renderer tests in a suitable test environment after implementation, including 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-session and session-launch-guards test files. Any existing contract changes outside the explicit v1 branch.

    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.js

    The 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-directory
    protection through the Podman service.

    After those pass, use the repository's task check gate 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:90 requires isolated HOME/data/triggers and :98 restricts current
    journeys 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.js and a test-only fixture script such
    as e2e/fixtures/trigger-session.cjs; add no production test IPC or map hooks.

    Run this journey in Linux CI with the existing e2e/fixtures.js isolation.
    Before publishing the trigger, save the fixture project's normal settings
    through the existing API: sandbox off, MCP emulation off, and preLaunchCmd
    equal 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. Main
    constructs its normal command (main.js:3095, :3107, :3170), but Node runs
    the fixture script with claude --resume <id> as arguments. The script records
    argv/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 opened result with resumed:true,
    wrote:false. Repeat with another request UUID for the same ID and assert
    resumed:false and one wrapper process in total. Click the new row afterwards
    and assert its ordinary terminal becomes visible without another launch.
    Use locator assertions/expect.poll, never fixed sleeps. Prove the journey
    goes 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:

    • The "older" list resume runs in the renderer only: openSessionNow() public/app.js:2493 -> window.api.openTerminal(..., false, resumeOptions) public/app.js:2630 -> ipcMain 'open-terminal' main.js:2828.
    • New session: launchNewSession(project, sessionOptions) public/app.js:2320 (the "+" path).
    • Stop: ipcMain 'stop-session' main.js:1964 (sidebar Stop).
    • second-instance ignores argv (main.js:3874); no protocol handler; the MCP bridge socket only has editor tools (src/main/integrations/mcp/mcp-bridge.js:52-96).
    • Trigger watcher src/main/triggers/trigger-watcher.js: only targets open sessions ('session not found', :1375); one result per trigger in processed/.result.json (:1059); chains exist; escape-key trigger exists; token bucket (.ai/contexts/trigger-watcher.md:136).
    • (triggers): let a session open sister sessions and follow their state #380 (open-session trigger with admission/cap) is specified but not implemented; (triggers): resume a closed session visibly and deliver a prompt to it #1022 does not depend on it.

    Decisions:

    1. One versioned family in the existing triggers dir: { "version": 1, "action": "open" | "stop", ... }. Unknown version or action is refused explicitly. Existing trigger formats (no version/action) keep working unchanged.
    2. Result per trigger, existing processed/.result.json, adds outcome: opened | closed | refused | not-found, plus reason and sessionId (the new id for "new"). The trigger file is removed after the result is written. A caller waits on the result with a bound.
    3. open {sessionId, prompt?}: reopen a closed session visibly through openSessionNow() with its resume guard and refusals unchanged, then deliver the prompt through the existing delivery path (same FIFO lock, readiness waits). If already open here: deliver only (outcome opened, resumed:false).
    4. open {new: {project, name}, prompt}: through launchNewSession, exactly like the "+" click: the project must be a known project; permission mode, sandbox and worktree come from the project's saved settings; the trigger takes NO launch options. A small fixed rate limit applies. Per-trigger options and fork side-quests remain (triggers): let a session open sister sessions and follow their state #380.
    5. stop {sessionId, before?}: optional before prompt 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.
    6. Name-to-id resolution stays with the caller (wake script); the trigger takes ids and project paths only.
    7. Visible sessions only: no claude -p, no hidden PTY. Window focus: the spec decides with a recommendation.

    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.

  3. devsuitup commented on Nov 8, 2026

    @devsuitup
    Owner

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions