Skip to content

(triggers): deliver a trigger to a remote session through the CLI messaging socket (#437) - #439

Merged
devsuitup merged 2 commits into
mainfrom
feat/437-remote-triggers
Oct 3, 2026
Merged

devsuitup merged 2 commits into
mainfrom
feat/437-remote-triggers

Conversation

@devsuitup

@devsuitup devsuitup commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

An unattached remote session listed in the pulled descriptors previously had no trigger target. A single trigger can now send through the existing messaging socket adapter when the global remoteTriggers setting is enabled; the default is false and no Settings control is added.

Local and attached terminals retain precedence. Enabled hosts are checked dynamically; collisions, chains, stale pulls, unknown fresh status, attachment during a wait, and cwd mismatches refuse the send. Remote idle waits are passive and require two distinct completed post-start pulls from the same host. Only exact /compact and /clear are allowed slash commands after trimming. Success is assumed only and captures the descriptor before writing; an ambiguous write returns send unconfirmed with written unknown.

The shared adapter adds structured failure codes and a per-host-session bucket of 30 prompts, refilling one every two seconds. It refunds definite failures and preserves the success object exactly as { ok: true }. Timeout dedupe remains reserved; other non-zero exits preserve the existing dedupe release behavior. Documentation and the Unreleased changelog entry describe the setting, passive waits, result fields and retry limits.

Validation

  • Node v24.18.0 in this worktree, using the existing node_modules junction. No install, commit, push, network access or live instance launch.
  • Initial tests-first run: 163 tests, 110 passed, 52 failed, 1 skipped (.work-files/red.log). Missing remote delivery, failure codes, rate cap, context and plural lookup caused the feature failures. U1 initially had an overly strict fixture assertion about Enter retries; it was corrected without changing the terminal path.
  • Existing safeguards U14, U15 and the guard-only inventory test U20 already passed at baseline. U4 is explicitly a future-edit guard. Their executed mutations supply the red evidence; the report does not claim they failed on the original behavior. Later strengthened assertions and context/indexer integration cases were validated by mutation rather than claiming pre-implementation chronology.
  • Final targeted run: 166 tests, 165 passed, 0 failed, 1 skipped. Command: node --test test/trigger-remote.test.js test/remote-send.test.js test/remote-index.test.js test/trigger-context.test.js test/remote-ssh-spawn-sites.test.js (.work-files/final-targeted.log).
  • Related run: 542 tests, 540 passed, 0 failed, 2 skipped (.work-files/related.log). Includes every trigger test, main wiring source checks, remote send/spawn/stop/index, sidebar send, placeholder merging, composer quiet window, terminal menu, setting defaults and changelog. The subsequently added same-host clock guard passed its own red/green regression and the full suite.
  • Separate red/green regressions preserve ISO sent_at and forbid combining pulls from different hosts (.work-files/sent-at-red.log, .work-files/host-clock-red.log, .work-files/host-clock-green.log).
  • ESLint over changed JavaScript files: exit 0, 0 errors, 6 existing main-process warnings (.work-files/eslint-touched.log). npm run lint: exit 0, 0 errors, 353 existing warnings (.work-files/npm-lint.log). git diff --check: exit 0.
  • npm test completed both stages: 3551 tests, 3485 passed, 3 failed, 63 skipped, 0 cancelled (.work-files/npm-test.log). The isolated watcher stage passed 119, skipped 1.
  • Sandbox-only failures, reproduced separately (.work-files/sandbox-failures.log): git-changes-file-real-git, "a git directory that is not called .git is refused by containment alone" cannot create customgit/HEAD; git-changes-runner-real-git, "a worktree whose main repository was deleted is reported, not withdrawn" cannot create wt/.git (Permission denied); ipc-path-validator, "allows ~/.aws/config" sees an unresolved parent because realpathSync.native returns EPERM on the home/.aws directory. The tested files and runtime modules are unchanged. Metadata-only reproduction: node .work-files/sandbox-evidence.cjs.

Mutations

36/36 mutations were detected with exit 1 in a disposable worktree-local copy. Each edit was restored in finally. No tracked source was mutated. U4 and U20 are guard-only tests. The first U6 mutation survived an assertion that included the final lookup; the freshness assertion was tightened, rerun green, and the same mutation then failed. No production change was needed for that survivor.

Reproduce all: node .work-files/mutate.cjs. This copies the current working tree sources and tests, links the existing dependencies, applies one edit at a time, and executes node --test --test-name-pattern= test/.test.js in the copy, with a 12-second bound per mutation. .work-files/mutations.json contains the exact edits and measured exit statuses. The copy uses current sources because the implementation is uncommitted; archiving HEAD alone would omit it.

Guard File Replacement Test selector Result
U2 trigger-watcher.js return { ok: true, submitted: SUBMITTED_ASSUMED, sessionId, command: text → return { ok: true, submitted: SUBMITTED_CONFIRMED, sessionId, command: text trigger-remote: U2: RED, exit 1
U3 trigger-context.js if (aliases.length > 1) return { aliases }; → if (false) return { aliases }; trigger-context: U3/U19/U23: RED, exit 1
U4 GUARD trigger-watcher.js const result = await ctx.remote.send(snapshot.alias, snapshot.descriptor, text); → const result = await ctx.remote.send('evil', snapshot.descriptor, text); trigger-remote: U4 GUARD RED, exit 1
U5 trigger-watcher.js if (chain !== undefined) return refuse('a chain cannot be sent → if (false) return refuse('a chain cannot be sent trigger-remote: U5: RED, exit 1
U6 trigger-watcher.js snapshot.at != null && snapshot.at >= started → snapshot.at != null trigger-remote: U6: RED, exit 1
U6 host clock trigger-watcher.js if (snapshot.alias !== initial.alias) return resolve(refuse('the session moved to another host while waiting; nothing was written')); → if (false) return resolve(refuse('the session moved to another host while waiting; nothing was written')); trigger-remote: U6: completed RED, exit 1
U7 trigger-watcher.js : REASON_REMOTE_NO_FRESH_PULL; → : REASON_CLI_BUSY; trigger-remote: U7: an unchanged RED, exit 1
U8 unknown trigger-watcher.js if (!['busy', 'waiting', 'shell'].includes(lastStatus)) return resolve(refuse(REASON_REMOTE_UNKNOWN_STATUS)); → if (!['busy', 'waiting', 'shell'].includes(lastStatus)) return resolve(null); trigger-remote: U8: fresh RED, exit 1
U8 shell trigger-watcher.js ['busy', 'waiting', 'shell'].includes(lastStatus) → ['busy', 'waiting'].includes(lastStatus) trigger-remote: U7/U8: fresh shell RED, exit 1
U9 trigger-watcher.js duringWait ? 'session exited during wait' : 'session not found' → duringWait ? 'not sent' : 'session not found' trigger-remote: U9: RED, exit 1
U10 poll trigger-watcher.js if (ctx.getPtyForSession(sessionId)) return resolve(refuse(REASON_REMOTE_ATTACHED)); → if (false) return resolve(refuse(REASON_REMOTE_ATTACHED)); trigger-remote: U10: a mid RED, exit 1
U10 final trigger-watcher.js if (ctx.getPtyForSession(sessionId)) return refuse(REASON_REMOTE_ATTACHED); → if (false) return refuse(REASON_REMOTE_ATTACHED); trigger-remote: U10: attachment RED, exit 1
U11 trigger-watcher.js path.posix.normalize(value); → path.posix.normalize(value).toLowerCase(); trigger-remote: U11: cwd guard targetMismatch RED, exit 1
U12 trigger-watcher.js error: result.maybeWritten ? 'send unconfirmed' → error: result.maybeWritten ? 'not sent' trigger-remote: U12: adapter (timeout|exit) RED, exit 1
U13 trigger-watcher.js status: snapshot.descriptor.status ?? null, → status: snapshot.descriptor.status ?? 'idle', trigger-remote: U13: absent RED, exit 1
U2 timestamp trigger-watcher.js sent_at: new Date(sentAtMs).toISOString() → sent_at: sentAtMs trigger-remote: U2: RED, exit 1
U14 trigger-watcher.js if (command.length > MAX_COMMAND_LEN) → if (false) trigger-remote: U14: command too long RED, exit 1
U14 controls trigger-watcher.js if (FORBIDDEN_COMMAND_RE.test(command)) → if (false) trigger-remote: U14: command contains RED, exit 1
U16 remote-send.js code: 'timeout', maybeWritten: true, → code: 'timeout', remote-send: U16: RED, exit 1
U16 success remote-send.js return { ok: true }; } return { send }; → return { ok: true, extra: true }; } return { send }; remote-send: U16: RED, exit 1
U17 capacity remote-send.js const RATE_CAPACITY = 30; → const RATE_CAPACITY = 31; remote-send: U17: shared RED, exit 1
U17 refill remote-send.js const RATE_REFILL_PER_MS = 0.5 / 1000; → const RATE_REFILL_PER_MS = 1 / 1000; remote-send: U17: shared RED, exit 1
U17 refund remote-send.js bucket.tokens = Math.min(RATE_CAPACITY, bucket.tokens + 1); → bucket.tokens = bucket.tokens; remote-send: U17: definite RED, exit 1
U18 remote-index.js return aliases; → return aliases.slice(0, 1); remote-index: U18/U23: RED, exit 1
U19 trigger-context.js if ('remote' in deps) Object.defineProperty → if (true) Object.defineProperty trigger-context: U3/U19/U23: RED, exit 1
U20 GUARD trigger-watcher.js const fs = require('fs'); → const cp = require('child_process'); const fs = require('fs'); trigger-remote: U20 GUARD RED, exit 1
U21 allowlist trigger-watcher.js if (!text) return refuse(REASON_REMOTE_SLASH); → if (!text) text = trimmed; trigger-remote: U21: unsupported RED, exit 1
U21 constant trigger-watcher.js text = REMOTE_SLASH_COMMANDS.find(value => value === trimmed); → text = REMOTE_SLASH_COMMANDS.includes(trimmed) ? command : undefined; trigger-remote: U21: allowed command /compact RED, exit 1
U22 main.js if ((settings.remoteTriggers ?? SETTING_DEFAULTS.remoteTriggers) === true) → if (true) trigger-remote: U22: main RED, exit 1
U23 remote-index.js isEnabled(alias) && list.some → list.some trigger-remote: U23: RED, exit 1
U24 trigger-watcher.js if (wait === 'none' && (snapshot.at == null || Date.now() - snapshot.at > snapshot.maxAgeMs)) → if (false) trigger-remote: U24: stale RED, exit 1
U25 trigger-context.js if (!session || session.exited) return null; → if (!session) return null; trigger-remote: U25: RED, exit 1
U26 trigger-watcher.js if (firstPull === null) firstPull = snapshot.at; → if (firstPull === null) { firstPull = snapshot.at; if (snapshot.descriptor.status === 'idle') return resolve(null); } trigger-remote: U26: RED, exit 1
U27 trigger-watcher.js if (acquireSessionLock) { → if (false) { trigger-remote: U27: RED, exit 1
U1 trigger-watcher.js const sessionEntry = ctx.getPtyForSession(sessionId); → const sessionEntry = null; trigger-remote: U1: RED, exit 1
U15 trigger-watcher.js if (remote) { → if (true) { trigger-remote: U15/U22: setting off RED, exit 1

Live-only checks: not performed

  • L1: a real unattached session receives a plain prompt and advances its status timestamp.
  • L2: a busy real session is sent only after two completed post-start pulls establish idle, using a 60-second refresh interval and a caller deadline that includes pull latency.
  • L3: whether a prompt during a permission dialog is queued or lost remains UNVERIFIED.
  • L4: the effect of /compact and /clear over the socket remains UNVERIFIED: command execution versus plain text.
  • L5: a real Windows-host pipe is refused with the key-file reason.
  • L6: a real tmux attachment keeps the terminal path, without channel: socket.
  • L7: identical text repeated within 30 seconds is refused.

These need a real host and an isolated running instance. This environment has no network. Node 20/22 with c8 and external CI were not run; only Node 24 is available. No independent reviewer loop or publication was attempted.

Boundaries and deviations

  • Work was confined to the supplied worktree; source changes remain uncommitted for the parent. No junction was deleted or traversed for deletion.
  • Commands used explicit bounds. The npm test watchdog fired at 180 seconds, but sandbox permissions rejected taskkill with Access denied. The finite suite was allowed to finish naturally; both stages completed and no verification process remains running. This exceeded the requested wall-clock bound. No timeout is being represented as a passing suite.
  • Session registration could not write the lifecycle lock (Access denied); no checkpoint could be created. No ownership or another session identity was reused.
  • No manual two-name spawn list was added: the existing spawn-site inventory already enumerates all root modules. An explicit U20 guard asserts both trigger modules remain in that inventory, and another forbids child_process imports.
  • The additional same-host guard refuses target reassignment during an idle wait, preserving the specification's two-pull proof and avoiding cross-host clock assumptions.

Files changed

  • .ai/contexts/session-cache.md
  • .ai/contexts/trigger-watcher.md
  • CHANGELOG.md
  • docs/automation.md
  • docs/remote-hosts.md
  • main.js
  • public/setting-defaults.js
  • remote-index.js
  • remote-send.js
  • test/remote-index.test.js
  • test/remote-send.test.js
  • test/remote-ssh-spawn-sites.test.js
  • test/trigger-context.test.js
  • trigger-context.js
  • trigger-watcher.js
  • test/trigger-remote.test.js

Round 2

The five review points are addressed against committed round 1, 8e35be1.
Changes remain in the supplied worktree for the parent to commit.

  • Remote command checks strip leading whitespace and U+200B–U+200D, U+2060 and U+FEFF before checking the first visible character. This chooses prefix normalization rather than banning these characters everywhere. First-visible ! and # are refused before waiting; unsupported / retains its existing refusal. Exact /compact and /clear still send the constants. Plain prompts, including middle !/#, retain the caller's original text. All three exact reason strings are documented in docs/automation.md and referenced from the trigger context document.
  • remoteTriggers is explicitly documented as having no UI and being maintainer-only for now. Its concrete storage is the remoteTriggers JSON property in the SQLite settings table, key = 'global', with the global object in value; other properties must be preserved. No configuration command is invented.
  • Each poll obtains one fresh settings snapshot, builds one enabled-alias set, and passes its predicate to the indexer. The context uses that snapshot instead of rereading the dependency. The regression executes the shipped main-process getter through the real context, counts exactly one getSetting('global') call across three aliases per lookup, and verifies changed enabled hosts and opt-in on subsequent accesses.
  • pruneRecent also deletes fully refilled idle rate buckets, including when a duplicate will be refused. Depleted buckets and buckets with a pending send remain. A pending counter is released in finally, preserving the existing refund and send-result behavior. Tests observe the shipped adapter's Maps in a VM and also verify the public rate cap after a bucket is recreated; no production inspection API is added.
  • U27 restores the previous SWITCHBOARD_TRIGGERS_DIR value, matching run(), and asserts restoration. Its before/after regression is also run with a pre-existing value.

Round 2 validation

  • Node v24.18.0, existing dependency junction; no installation, stash, commit, push, network access or live launch.
  • Baseline, all 37 test/trigger-*.test.js and test/remote-*.test.js files: 816 tests, 810 passed, 0 failed, 6 skipped, exit 0 (.work-files/round2-before.log).
  • Tests first: selected command-prefix, setting-read and bucket regressions yielded 27 tests, 13 passed, 14 failed, exit 1 (.work-files/round2-red.log). Failures showed commands proceeding instead of refusing, five settings reads instead of one, and retained full buckets. BOM trimming, plain prompts and existing allow-list cases already passed.
  • U27 with SWITCHBOARD_TRIGGERS_DIR=round2-previous-directory: 1 failed before restoration, 1 passed after (.work-files/round2-u27-red.log, .work-files/round2-u27-green.log).
  • Selected regressions and existing context/opt-in coverage: 31 passed, 0 failed, exit 0 (.work-files/round2-green.log).
  • Diff review exposed the provisional cleanup occurring after the dedupe refusal. The bucket test was strengthened to trigger pruning with a duplicate; it failed before moving cleanup into pruneRecent, then both pruning tests passed (.work-files/round2-prune-dedupe-red.log, .work-files/round2-prune-dedupe-green.log).
  • Final rerun of all 37 requested files: 835 tests, 829 passed, 0 failed, 6 skipped, 0 cancelled, exit 0, 95.8 seconds (.work-files/round2-final.log). Reproduce in PowerShell: $files = Get-ChildItem test/trigger-*.test.js,test/remote-*.test.js | Select-Object -ExpandProperty FullName; node .work-files/run-bounded.cjs round2-final --test --test-concurrency=4 $files. Each test command has a 180-second bound.
  • Additional main wiring, sidebar send, placeholder merge, composer quiet-window, terminal menu, settings-default and changelog tests: 85 passed, 0 failed, exit 0 (.work-files/round2-wiring.log).
  • npx --no-install eslint main.js remote-send.js trigger-context.js trigger-watcher.js test/remote-send.test.js test/trigger-remote.test.js: exit 0, 0 errors, 6 existing main-process warnings (.work-files/round2-eslint.log), 30-second bound. git diff --check: exit 0.

Round 2 mutations

Final result: 8/8 mutations detected through assertion failures, each exit 1 with no timeout. Sources are copied to a disposable worktree-local directory and restored in finally; tracked runtime files are never mutated. Reproduce: node .work-files/mutate-round2.cjs. Exact replacements and results: .work-files/round2-mutations.json; detailed outputs: .work-files/round2-mutation-0.log through round2-mutation-7.log. Each mutation has a 12-second bound. The first U27 mutation accidentally targeted the run() helper and survived; its anchor was corrected to U27 itself, which then failed as required.

Guard Mutation Test selector Result
Invisible command prefix Replace normalization with command.trim() U21: invisible prefix RED, exit 1
Bash mode Disable the leading ! guard U21: mode prefix RED, exit 1
Memory mode Disable the leading # guard U21: mode prefix RED, exit 1
Idle bucket cleanup Remove bucket deletion U17: pruning RED, exit 1
Pending bucket retention Remove the zero-pending condition U17: pruning retains RED, exit 1
One context snapshot Reintroduce the second dependency read in lookup U22: each RED, exit 1
One settings read across aliases Reintroduce getSetting inside the host predicate U22: each RED, exit 1
U27 environment restoration Delete the previous value instead of restoring it U27: with a pre-existing environment value RED, exit 1

Round 2 limits and files

  • All five requested changes and local checks are done. The full npm test suite was not repeated in this focused round; its earlier result above is historical evidence, not a fresh round 2 result. No round 2 test failure was attributed to sandbox restrictions.
  • Required CI red/green evidence and Node 20/22 with c8 remain unobserved because this dispatch permits no network or publication. The regressions are in files included unconditionally by scripts/run-tests.js, invoked by the existing test workflow's coverage job. Local red/green evidence does not claim CI chronology.
  • Command guards exercise the real file watcher and result files with the remote send port replaced. A real socket/host and live Electron instance are outside this dispatch. The getter/count regression exercises shipped wiring without launching Electron; bucket retention is an internal memory-lifetime property measured with Map instrumentation. L1–L7, particularly slash-command effects over the socket, remain UNVERIFIED.
  • Native-session registration returned Access is denied for the continuity lock; reading that same session reported it was not registered, so no checkpoint could be created. No other session identity was reused. All verification processes have completed.
  • Tracked files changed in round 2: trigger-watcher.js, trigger-context.js, main.js, remote-send.js, test/trigger-remote.test.js, test/remote-send.test.js, docs/automation.md, .ai/contexts/trigger-watcher.md. This PR body and the new validation artifacts are worktree-local scratch files. The existing round 1 changelog entry for (triggers): deliver a trigger to a remote session through the CLI messaging socket #437 remains the entry for this PR.

…saging socket

A trigger naming a remote session id had no target. With the opt-in remoteTriggers setting, a single trigger to a session that is not attached locally is sent over the session's messaging socket on an enabled host; idle is read from the pulled descriptor, slash commands go through a fixed allow-list, and success reads assumed, never delivered.

Closes #437
@devsuitup

Copy link
Copy Markdown
Owner Author

Review at 8e35be1: approved, no blocking finding; all checks green. 13 mutations on the security-relevant guards (gating, slash allow-list, constant sent, PTY precedence on every poll and before the write, enabled-host filter, no content in argv, ambiguous id, age bound, second-pull rule, absent status, bucket, chain refusal) all caught. Before merge: the remote prefix refusal keys on trim().startsWith('/'), which lets \u200B/model through and does not cover ! (bash mode) or # (memory); the docs say to set remoteTriggers without saying how; getSetting is read ~20 times a second per waiting trigger; two nits (bucket pruning, test env restore). Fixing in a second round. Live checks L1-L7 (notably slash commands over the socket) remain unverified.

The remote refusal keyed on trim().startsWith('/'), which let a zero-width character hide a slash command and did not cover ! (bash mode) or # (memory). Also read the setting and enabled hosts once per poll, prune idle rate buckets, and document that remoteTriggers has no UI yet.

Refs #437
@devsuitup

Copy link
Copy Markdown
Owner Author

Delta review at 9d59345: approved. Leading whitespace and zero-width characters are stripped before refusing /, ! and # for remote targets (only the exact /compact and /clear constants pass); mutations of the normalization, the ! refusal, the pending guard and the bucket pruning all caught; all checks green. Merging. Follow-up: other format characters (bidi controls, soft hyphen, …) still survive the normalization; tracked separately — the feature is opt-in and off by default.

@devsuitup
devsuitup merged commit 321ce33 into main Oct 3, 2026
12 checks passed
@devsuitup
devsuitup deleted the feat/437-remote-triggers branch October 3, 2026 16:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant