Skip to content

feat(triggers): self-authored watcher scripts that wake the bot - #96

Merged
dylanneve1 merged 7 commits into
mainfrom
feat/triggers
May 13, 2026
Merged

feat(triggers): self-authored watcher scripts that wake the bot#96
dylanneve1 merged 7 commits into
mainfrom
feat/triggers

Conversation

@claudiusthebot

Copy link
Copy Markdown
Collaborator

Summary

A new "trigger" subsystem: bot-authored long-running scripts (bash / python / node) that run as supervised subprocesses and signal back via stdout to fire wake-up messages into the originating chat. Closes the gap between a fixed cron schedule and a one-shot timer — for cases where the trigger condition is arbitrary code, not a calendar.

Direct response to ScheduleWakeup being banned outside /loop mode: the model now has a real, robust way to schedule its own follow-ups instead of poking at a Claude Code primitive that hangs the dispatcher.

Examples of what it unlocks:

  • "Wake me when PR #35337 merges" — a 4-line bash polling gh pr view, exit 0 on merge.
  • "Tell me if my Polymarket position swings >5%" — python loop emitting TALON_FIRE: on every threshold crossing, never exits.
  • "Ping me when this URL returns 200" — bash with curl --fail.

The contract (the "standard")

Bot writes the script body. Talon spawns it under the right interpreter, captures stdout/stderr, watches for signals.

  • Mid-run: any stdout line beginning with TALON_FIRE: <text> fires a wake-up immediately. Script keeps running. Use for watchers that emit multiple events.
  • Exit 0: final fire with the tail of stdout/stderr as payload.
  • Exit non-zero: error fire with exit code + log tail.
  • Hard timeout (default 24h, max 7d): SIGTERM → 5s grace → SIGKILL → "timed_out" fire.

Wake-ups go through the existing dispatcher.execute() with source: "trigger", senderName: "Trigger" — same path cron "query" jobs already use. The bot receives a system-prefixed message containing the trigger name, status, and payload, then decides whether to message the user, take an action, or do nothing.

Tools exposed

  • trigger_create(name, language, script, timeout_seconds?, description?) — write + spawn
  • trigger_list — status, fire count, last error per trigger in this chat
  • trigger_cancel(trigger_id) — SIGTERM (then SIGKILL after 5s)
  • trigger_logs(trigger_id, lines?) — tail of stdout + stderr
  • trigger_delete(trigger_id) — cancel if running, then rm script + log

Architecture

Modeled directly on the existing cron subsystem:

  • src/storage/trigger-store.ts — JSON store at ~/.talon/data/triggers.json, dirty-flag auto-save, restart-cleanup that reaps any `running`/`pending` left over from a crashed process.
  • src/core/triggers.ts — supervisor with per-trigger child handle, in-memory line buffer (so fire payloads don't race with log file flushing), readline-based TALON_FIRE: detection, hard timeout, SIGTERM/SIGKILL escalation.
  • src/core/tools/triggers.ts — 5 tool definitions, new triggers tag.
  • gateway-actions.ts — bridge handlers with name-uniqueness check, per-chat cap (5 active), language/script/timeout validation.
  • paths.ts~/.talon/data/trigger-runs/<chatId>/<id>.{sh,py,js} for scripts, <id>.log for runs. Scripts written 0o700; logs 0o600.
  • bootstrap.ts — `loadTriggers` + `initTriggers({ execute })` + `resumeAfterRestart`.
  • index.ts — `shutdownTriggers` (await SIGTERM grace) + `flushTriggers` in graceful + crash paths.
  • types.ts — `source: "trigger"` added to `ExecuteParams`.

Limits / lifecycle

  • 5 active triggers per chat (soft cap to prevent runaway). `trigger_list` so the bot can see what's already running before adding more.
  • Default 24h hard timeout, max 7d.
  • Children are killed on Talon shutdown — triggers do not survive restart. Any record left in `running`/`pending` on next load is marked `terminated` so the bot can decide whether to recreate.
  • 4 KB cap on fire payloads; 80-line in-memory rolling buffer per trigger feeds the payload (independent of log file flushing).
  • Script body capped at 64 KB. Name must match `/^[a-zA-Z0-9 _.-]{1,64}$/` and is unique per chat.
  • Path-traversal-safe: `chatId` sanitised to `[a-zA-Z0-9_-]` before being used in file paths.

What this deliberately doesn't do

  • No restart survival. The bot was clear: simpler is better. Process group dies with us, store reaps state, bot recreates if needed. Future v2 could add PID-alive detection and log re-attach.
  • No nested triggers from a fire-handler. Not blocked at the type level — the per-chat cap is the only guard. Anti-loop protection lives in the cap, not in syntax restrictions.
  • No `subscribe_to_event` / webhook endpoint. Out of scope for this PR. Triggers are the polling/script-driven half; an HTTP webhook surface would be a separate change.

Test plan

  • `src/tests/trigger-store.test.ts` — 22 tests (validation, CRUD, path helpers, restart cleanup, log tail reading, fs mocked)
  • `src/tests/triggers.test.ts` — 8 supervisor tests with real bash subprocesses (clean exit / error exit / mid-run multi-fire / cancel / hard timeout / unsupported language / shutdown / log file content)
  • Compose-tools tag assertion updated to include `triggers`
  • `tsc --noEmit` clean
  • Full suite: 1397 passing, 0 failing
  • Smoke test on the live VPS once merged: `trigger_create` a 60-second `sleep 5` bash script, observe wake-up; cancel an in-flight one; hit the timeout

🤖 Generated with Claude Code

@claudiusthebot

Copy link
Copy Markdown
Collaborator Author

Rebased onto 9b0db075 (main / v1.10.0 + CI improvements). Clean rebase, no conflict sites. New head: 82af753.

typecheck ✅ · format:check ✅ — CI running.

@claudiusthebot

Copy link
Copy Markdown
Collaborator Author

🤖 Heartbeat #175 — branch coverage fix applied

CI was failing with Coverage for branches (59.22%) does not meet global threshold (60%) — introduced when PR #121 added the 60% branch coverage gate.

Added src/__tests__/triggers-extended.test.ts (commit 9096518) with 14 tests covering the previously-untested branches:

Test group Branches covered
triggers — alternate languages commandForLanguage python + node switch arms
triggers — idempotency spawnTrigger early-return guard (already-running)
triggers — cancelTrigger cancelTrigger false-return (no child for id)
triggers — resumeAfterRestart no-deps guard, empty store, matching trigger, already-fired, old (>5 min)
trigger-store — branch coverage updateTrigger + deleteTrigger with unknown id
triggers — large stdout payload truncation FIRE_PAYLOAD_MAX_BYTES buffer-trimming path
triggers — finalizeExit status branch cancelled-status exit + non-zero exit code

All 14 tests pass locally; CI will confirm coverage ≥ 60%.

@claudiusthebot

Copy link
Copy Markdown
Collaborator Author

Coverage fix — d3cb661 — two CI failures from the previous commit resolved:

1. Windows N22 test failure (trigger-store.test.ts > path helpers > triggerScriptPath)

  • Root cause: regex used Unix / separators but resolve() returns \ on Windows. macOS N22 and N24 passed; only Windows N22 failed.
  • Fix: const norm = (p: string) => p.replace(/\\/g, "/") — normalise before matching.

2. Ubuntu N22 coverage 59.67% < 60% (N24 was fine)

  • Root cause: V8 v11 (Node 22) instruments more branches than V8 v12 (Node 24) for the same code — triggers-extended.test.ts added enough for N24 but fell 0.33% short on N22.
  • Fix: 4 additional targeted tests:
    • persistNow() — covers dirty = true; save() (lines 159-160 of trigger-store.ts)
    • readTriggerLogTail catch via EISDIR (line 329 — readFileSync on a directory throws)
    • readTriggerLogTail truncated path (line 327)
    • fireWakeUp dispatch-error catch (line 394 of triggers.tsexecute() rejects)

typecheck ✅ · format:check ✅ · 18 extended tests pass locally · CI running on d3cb661

@claudiusthebot

Copy link
Copy Markdown
Collaborator Author

Coverage fix round 3 — CATCH #21 (hb #179)

d3cb661 (hb #177) was confirmed as MERGEABLE from the GitHub API in hb #178, but the actual CI run 25588573095 failed: Ubuntu N22 at 59.67% (< 60% threshold). The API's MERGEABLE reflects conflict state, not CI result — hb #178 made the mistake of conflating the two.

New head: d5ca2f9 — 7 additional tests:

  • Empty-stdout: fireWake's trimmed ? ... : '(no output)' false branch
  • Mid-run TALON_FIRE:: handleStdoutLine true branch + fireWake terminal=false ('signalled' header)
  • validateLanguage false paths: includes() false + typeof short-circuit
  • sanitizeChatId with special chars: replace() regex path
  • languageExtension direct call: all 3 switch arms
  • getTriggerByName returning undefined

25 tests total, all passing locally. Pushing to CI.

@claudiusthebot

Copy link
Copy Markdown
Collaborator Author

Coverage fix round 4 (6e09ec4) — ALL 15 CI CHECKS GREEN ✅ (including Ubuntu N22, Windows N22, all 3 Functional platforms). All 1744 tests pass.

This PR is now MERGEABLE and CI-clean. Six code PRs are all ready in one pass:

PR Title Status
#89 feat(gateway): force_dream + set_config ALL CI GREEN ✅
#90 robustness + test coverage ALL CI GREEN ✅
#86 feat(plugins): self-healing lifecycle ALL 25 CI GREEN ✅
#96 feat(triggers) ALL CI GREEN ✅
#62 feat(observability) ALL CI GREEN ✅
#122 fix(claude-sdk): MCP prefix turn-terminator ALL CI GREEN ✅

Suggested merge order: #89 → #90 → #86 → #96 → #62 → #122

@dylanneve1
dylanneve1 requested a review from Copilot May 12, 2026 12:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new trigger subsystem that lets the bot create and supervise long-running watcher scripts (bash/python/node) which can wake the bot by emitting TALON_FIRE: lines or by exiting, bridging the gap between cron schedules and one-shot timers.

Changes:

  • Added persistent trigger metadata store and on-disk layout for scripts/logs under ~/.talon/data/trigger-runs/.
  • Implemented a trigger supervisor that spawns/kills children, tails logs, detects TALON_FIRE: signals, and dispatches wake-ups via the existing dispatcher.
  • Exposed new trigger tools (trigger_create/list/cancel/logs/delete) and wired trigger lifecycle into bootstrap + shutdown, with new unit tests for store/supervisor behavior.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/util/paths.ts Adds trigger data directories/files.
src/util/log.ts Adds triggers log component.
src/util/config.ts Documents trigger tools/protocol in the system prompt.
src/storage/trigger-store.ts New persistent store + validation + path helpers for triggers.
src/core/triggers.ts New supervisor to spawn/monitor scripts and fire wake-ups.
src/core/gateway-actions.ts Adds gateway action handlers for trigger tool bridging.
src/core/types.ts Extends dispatcher ExecuteParams source union with trigger.
src/core/tools/types.ts Adds triggers tool tag.
src/core/tools/triggers.ts New tool definitions for trigger operations.
src/core/tools/index.ts Registers trigger tools in ALL_TOOLS.
src/bootstrap.ts Loads triggers + initializes/resumes trigger subsystem on startup.
src/index.ts Shuts down trigger children and flushes trigger store on exit/crash paths.
src/tests/trigger-store.test.ts Unit tests for trigger-store persistence/validation/helpers.
src/tests/triggers.test.ts Supervisor tests with real subprocesses (bash) and lifecycle cases.
src/tests/triggers-extended.test.ts Extended branch coverage for supervisor/store edge cases.
src/tests/compose-tools.test.ts Asserts triggers tag is present.
src/tests/mcp-launcher.test.ts Adds missing-file branch coverage for launcher.
src/tests/cleanup-registry.test.ts Adds non-Error throw branch coverage.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/core/triggers.ts
Comment on lines +202 to +206
if (line.startsWith(FIRE_PREFIX)) {
const payload = line.slice(FIRE_PREFIX.length).trim();
fireWake(triggerId, "fired", payload, /* terminal */ false).catch((err) =>
logError("triggers", `mid-run fire failed [${triggerId}]`, err),
);
Comment thread src/core/triggers.ts
const logStream = createWriteStream(trigger.logPath, {
flags: "a",
mode: 0o600,
});
Comment thread src/core/triggers.ts Outdated
Comment on lines +364 to +366
// Truncate payload — we don't want a runaway script blowing out the prompt
const trimmed = (payload ?? "").slice(0, FIRE_PAYLOAD_MAX_BYTES);

Comment on lines +280 to +282
if (!t) return undefined;
Object.assign(t, updates);
dirty = true;
Comment thread src/storage/trigger-store.ts Outdated
timeoutSeconds: number;
/** Exit code on terminal status. */
exitCode?: number;
/** Number of mid-run TALON_FIRE: lines emitted. */
Comment thread src/core/gateway-actions.ts Outdated
Comment on lines +424 to +428
text:
`Created trigger "${name}" (id: ${id})\n` +
`Language: ${lang}\n` +
`Timeout: ${timeoutSeconds}s\n` +
`Status: running`,
claudiusthebot pushed a commit that referenced this pull request May 12, 2026
… on terminal, etc.

Address 5 of the 6 Copilot review threads on PR #96. The 6th (mid-run
TriggerStatus rename) is a larger semantic refactor and deferred to a
later commit; this commit pairs the smallest reasonable fix at each
site with regression tests where they're cleanly testable.

1. src/core/triggers.ts:128 — log WriteStream missing error handler.
   createWriteStream() with no .on("error") emits an unhandled
   EventEmitter `error` event if the log file becomes unwritable
   (disk full, permission flip), which crashes the Node process. Added
   a logError-routing handler so disk-tier failures degrade gracefully
   without killing the supervisor.

2. src/storage/trigger-store.ts:60 — fireCount doc/semantics mismatch.
   The doc-comment claimed "Number of mid-run TALON_FIRE: lines" but
   the supervisor increments fireCount for every wake (mid-run and
   terminal). Fixed the comment to match the actual semantics; same
   counter, accurate meaning. Avoids breaking any caller that already
   relies on fireCount as a total.

3. src/core/triggers.ts:fireWake — mid-run prompt says "Status: fired"
   even when terminal=false, which can mislead downstream handling
   into treating an in-flight watcher as a completed run. Decoupled
   the prompt-display status from the on-disk TriggerStatus enum:
   non-terminal fires now show "Status: signalled" in both the header
   and the "Status:" line. Terminal fires unchanged.

4. src/core/triggers.ts:bufferAsPayload + fireWake — FIRE_PAYLOAD_MAX_BYTES
   truncation used String.prototype.slice (UTF-16 code units), so a
   payload of N multi-byte characters could exceed the documented byte
   cap and split a character mid-codepoint. Introduced truncateUtf8Tail/
   truncateUtf8Head helpers that encode to UTF-8 bytes, slice on a byte
   boundary, then walk over UTF-8 continuation bytes (10xxxxxx) so we
   never cut a multi-byte sequence in half. The byte cap is now actually
   enforced as bytes.

5. src/storage/trigger-store.ts:282 + supervisor — updateTrigger only
   marks the store dirty (10s autosave window). For terminal status
   transitions (errored/cancelled/timed_out/fired) a crash inside that
   window would leave on-disk status as "running", which loadTriggers()
   then misclassifies as "terminated by previous restart". Added
   persistNow() calls at the four supervisor transition sites:
   failTrigger, cancelTrigger, the hard-timeout handler, and the
   finalizeExit path. The on-disk store now always agrees with the
   in-memory state across terminal transitions.

6. src/core/gateway-actions.ts:trigger_create — always returned
   "Status: running" even when spawnTrigger() failed without throwing
   (unsupported language slipping past validation, child.pid undefined,
   failTrigger() routing). With #5 above, the store now reflects the
   real state synchronously, so we re-read getTrigger(id) after spawn
   and return ok:false + lastError when it landed in "errored", or
   surface the actual status string otherwise. Callers no longer get
   false success responses.

Regression tests (triggers-extended.test.ts):
- mid-run prompt asserts "Status: signalled" appears and "Status: fired"
  does NOT, on a still-running trigger. Covers fix #3.
- multi-byte UTF-8 payload (2000× 💧 = 8000 bytes vs 4000 string length)
  asserts the prompt's byte length stays bounded AND contains no
  Unicode replacement character (U+FFFD), which would indicate a
  mid-codepoint split. Covers fix #4.

The remaining Copilot thread (introducing a dedicated non-terminal
status enum value, replacing the TriggerStatus|"signalled" display-
status hack) is a wider type change with API/store implications;
better tackled in a follow-up than bundled into a defensive sweep.

Verification:
- npx vitest run → 1747/1748 pass (the 1 failure is the pre-existing
  package.functional "Stopped" expectation that fails whenever a live
  Talon daemon is running on the host — same env-dependent failure
  noted in PR #144 and PR #90's verification blocks).
- npx vitest run src/__tests__/triggers* src/__tests__/trigger-store* →
  74/74 pass (35 in triggers-extended including 2 new, 22 in
  trigger-store, 17 in triggers).
- npx tsc --noEmit → clean.
- npx prettier --check on all 4 changed files → clean.
- npm run lint → 0 errors in changed files (10 pre-existing warnings
  elsewhere unchanged).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
dylanneve1 pushed a commit that referenced this pull request May 13, 2026
Four test failures in the PR #156 CI run all reflected old assertions
that match the bugs the PR explicitly fixes:

- cron-store: "does not write when not dirty" was asserting the very
  bug being fixed (flush should always persist defensively, matching
  flushSessions/flushHistory). Updated to assert writeFileSync IS called.
- errors: "uses extracted status instead of 429 default" was relying
  on '200' being matched by the old [2-5]\d{2} regex — the whole point
  of the PR is to stop matching 2xx/3xx. Switched the example to 418
  and added a regression test that 200ms in an error message no longer
  pollutes status.
- dream (timeout fake-timers): the mocked query awaited a never-resolving
  Promise, so the new `await agentPromise.catch(() => {})` (matching the
  heartbeat.ts pattern that waits for the subprocess before releasing the
  lock) hung the test. Switched to a controllable promise and resolve it
  after the timeout fires to simulate the agent finishing post-timeout.
- dream (executeDream catch): cascading failure from the same hang —
  vi.useRealTimers() at the end of the timeout test was unreachable, so
  fake timers leaked into the next test's `setTimeout(r, 0)`. Fixed by
  unhanging the timeout test.

Plus prettier --write on formatting.ts and history.ts (the PR's edits
nudged both files past the line-length limit).

Verified locally: 1900/1901 pass (the one remaining failure is the
pre-existing package.functional env-dependent flake, same as PR #144/#90/#96).
typecheck clean, prettier --check clean on all touched files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
dylanneve1 added a commit that referenced this pull request May 13, 2026
… component, flush consistency, status regex) (#156)

* fix: six correctness and safety bugs found by code review

- dream.ts: capture and clear the 10-minute timeout handle in a finally
  block, and call .unref() on it.  Without this the timer kept the Node.js
  event loop alive after a successful dream run and, when it eventually
  fired, rejected a Promise with no handler → UnhandledPromiseRejection
  warning.  The fix mirrors the already-correct pattern in heartbeat.ts
  and additionally waits for the agent to finish before releasing the
  dreaming lock on timeout, preventing overlapping dream runs.

- formatting.ts: HTML-escape the URL before embedding it in the <a href>
  attribute.  A crafted markdown link such as [x](https://a.com" onX="y)
  could inject arbitrary HTML attributes into the generated anchor tag.

- history.ts: fix two log() / logError() calls that passed "sessions" as
  the component instead of "history", causing history-load events to appear
  under the wrong tag in structured logs.

- cron-store.ts / chat-settings.ts: add `dirty = true` before the final
  save() in flushCronJobs() and flushChatSettings() to guarantee the store
  is written on shutdown regardless of auto-save timer timing.  Matches
  the defensive pattern already used by flushSessions() and flushHistory().

- errors.ts: narrow the HTTP status-code regex from /\b([2-5]\d{2})\b/ to
  /\b([45]\d{2})\b/.  Only 4xx/5xx codes are acted on by the classifier;
  matching 2xx/3xx codes was harmless for control flow but could stamp a
  spurious status (e.g. 200 from "took 200ms") onto a TalonError, making
  diagnostic logs misleading.

https://claude.ai/code/session_01Xq1CTjS2UG5ErEbqVaELRn

* test+style: align tests with #156 corrected behaviour + prettier

Four test failures in the PR #156 CI run all reflected old assertions
that match the bugs the PR explicitly fixes:

- cron-store: "does not write when not dirty" was asserting the very
  bug being fixed (flush should always persist defensively, matching
  flushSessions/flushHistory). Updated to assert writeFileSync IS called.
- errors: "uses extracted status instead of 429 default" was relying
  on '200' being matched by the old [2-5]\d{2} regex — the whole point
  of the PR is to stop matching 2xx/3xx. Switched the example to 418
  and added a regression test that 200ms in an error message no longer
  pollutes status.
- dream (timeout fake-timers): the mocked query awaited a never-resolving
  Promise, so the new `await agentPromise.catch(() => {})` (matching the
  heartbeat.ts pattern that waits for the subprocess before releasing the
  lock) hung the test. Switched to a controllable promise and resolve it
  after the timeout fires to simulate the agent finishing post-timeout.
- dream (executeDream catch): cascading failure from the same hang —
  vi.useRealTimers() at the end of the timeout test was unreachable, so
  fake timers leaked into the next test's `setTimeout(r, 0)`. Fixed by
  unhanging the timeout test.

Plus prettier --write on formatting.ts and history.ts (the PR's edits
nudged both files past the line-length limit).

Verified locally: 1900/1901 pass (the one remaining failure is the
pre-existing package.functional env-dependent flake, same as PR #144/#90/#96).
typecheck clean, prettier --check clean on all touched files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claudius <claudiusthebot@gmail.com>
claudiusthebot and others added 7 commits May 13, 2026 14:09
Triggers are bot-authored long-running scripts (bash / python / node)
that run as supervised subprocesses and signal back via stdout to fire
wake-up messages into the originating chat. Built for the case where a
fixed cron schedule doesn't fit — "wake me when this PR merges", "tell
me if BTC moves >5%", "watch this URL until it returns 200".

Contract:
  - Mid-run: a stdout line `TALON_FIRE: <text>` fires immediately and the
    script keeps running (multi-event watchers).
  - Exit 0: final fire with the tail of stdout/stderr as payload.
  - Exit non-zero: error fire with exit code + log tail.
  - Hard timeout (default 24h, max 7d): SIGTERM → SIGKILL → timed_out fire.

Tools exposed: trigger_create, trigger_list, trigger_cancel,
trigger_logs, trigger_delete. Per-chat cap of 5 active. Children are
killed on Talon shutdown — they do not survive a restart, and any
trigger left in running/pending on load is reaped to "terminated".

New modules:
  - src/storage/trigger-store.ts: persistence + validation
  - src/core/triggers.ts: supervisor (spawn, line buffers, fire dispatch)
  - src/core/tools/triggers.ts: 5 tool definitions
  - gateway-actions.ts: trigger_* bridge handlers
  - paths.ts: ~/.talon/data/trigger-runs/<chatId>/ for scripts and logs
  - bootstrap.ts: loadTriggers + initTriggers + resumeAfterRestart
  - index.ts: shutdownTriggers + flushTriggers in shutdown paths

Tests: 30 new (22 store + 8 supervisor including real-bash spawn
integration). 1397/1397 passing.
…erRestart/store

Adds targeted tests that bring branch coverage above the 60% global
threshold imposed by PR #121.  Exercises: commandForLanguage python &
node paths, spawnTrigger idempotency guard, cancelTrigger false-return,
resumeAfterRestart (no-deps / empty / matching / already-fired / old),
trigger-store unknown-id paths, FIRE_PAYLOAD_MAX_BYTES truncation, and
finalizeExit status-branch (cancelled exit + non-zero exit code).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two CI failures in the coverage-fix commit (9096518):

1. Windows N22 — trigger-store.test.ts: triggerScriptPath regex used Unix
   forward-slashes but resolve() returns backslashes on Windows. Fixed by
   normalising path separators before matching.

2. Ubuntu N22 — branch coverage 59.67% < 60% (threshold differs from N24
   due to V8 v11 vs v12 branch instrumentation). Added 4 more targeted tests
   in triggers-extended.test.ts:
   - trigger-store persistNow() path (lines 159-160)
   - readTriggerLogTail catch path via EISDIR (line 329)
   - readTriggerLogTail truncated:true path (line 327)
   - fireWakeUp dispatch-error catch path (line 394 of triggers.ts)

All 18 extended tests pass. typecheck ✅ · format:check ✅.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… and store helpers

Ubuntu Node 22 (V8 v11) instruments more branches than Node 24 (V8 v12),
causing coverage to land at 59.67% on N22 while passing on N24.

Added 7 new tests to triggers-extended.test.ts:
- Empty stdout: covers fireWake's `trimmed ? ... : (no output)` false branch
- Mid-run TALON_FIRE: covers handleStdoutLine true branch + fireWake
  terminal=false ("signalled" header path)
- validateLanguage false paths: includes() false + typeof short-circuit
- sanitizeChatId with special chars: confirms replace() regex path
- languageExtension direct call: covers all 3 switch arms explicitly
- getTriggerByName returning undefined: covers find() undefined path

Also imports getTriggerByName, validateLanguage, sanitizeChatId, and
languageExtension from trigger-store for direct testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…2 CI pass

Add 15 new tests across 4 test files to cover previously-unreachable branches,
specifically the ones V8 v11 (Node 22) counts but were uncovered since hb #179:

trigger-store.test.ts (+9 tests):
- loadTriggers: file-not-present, non-object JSON, corrupt primary (3 variants)
- Backup parses as null (line 94 ternary false arm)
- save() catch: Error throw and non-Error string throw (both instanceof arms)
- readTriggerLogTail: non-Error throw (false arm of err instanceof Error)

triggers-extended.test.ts (+5 new describe blocks):
- fireWake with undefined payload (payload ?? '' false arm)
- fireCount undefined treated as 0 (fireCount ?? 0 false arm)
- finalizeExit with null exit code — signal kill (code ?? undefined false arm)
- handleStdoutLine with no lineBuffer entry (pushBufferLine if(!buf) true arm)
- shutdownTriggers when no children running (if(children.size===0) true arm)
- Child process error event handler (line 152 handler covered)
- Timeout timer fires after child already exited (if(!c) return true arm)

cleanup-registry.test.ts (+1 test):
- Handler throws a non-Error string (err instanceof Error false arm)

mcp-launcher.test.ts (+1 test):
- ensureLauncher throws when file does not exist (if(!existsSync) true arm)
  Uses vi.doMock to intercept node:fs before fresh module import.

Node 24 local result: 60.26% (2146/3561). Previous Node 22 CI: 59.73%.
1744 tests, all passing. tsc clean, prettier clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… on terminal, etc.

Address 5 of the 6 Copilot review threads on PR #96. The 6th (mid-run
TriggerStatus rename) is a larger semantic refactor and deferred to a
later commit; this commit pairs the smallest reasonable fix at each
site with regression tests where they're cleanly testable.

1. src/core/triggers.ts:128 — log WriteStream missing error handler.
   createWriteStream() with no .on("error") emits an unhandled
   EventEmitter `error` event if the log file becomes unwritable
   (disk full, permission flip), which crashes the Node process. Added
   a logError-routing handler so disk-tier failures degrade gracefully
   without killing the supervisor.

2. src/storage/trigger-store.ts:60 — fireCount doc/semantics mismatch.
   The doc-comment claimed "Number of mid-run TALON_FIRE: lines" but
   the supervisor increments fireCount for every wake (mid-run and
   terminal). Fixed the comment to match the actual semantics; same
   counter, accurate meaning. Avoids breaking any caller that already
   relies on fireCount as a total.

3. src/core/triggers.ts:fireWake — mid-run prompt says "Status: fired"
   even when terminal=false, which can mislead downstream handling
   into treating an in-flight watcher as a completed run. Decoupled
   the prompt-display status from the on-disk TriggerStatus enum:
   non-terminal fires now show "Status: signalled" in both the header
   and the "Status:" line. Terminal fires unchanged.

4. src/core/triggers.ts:bufferAsPayload + fireWake — FIRE_PAYLOAD_MAX_BYTES
   truncation used String.prototype.slice (UTF-16 code units), so a
   payload of N multi-byte characters could exceed the documented byte
   cap and split a character mid-codepoint. Introduced truncateUtf8Tail/
   truncateUtf8Head helpers that encode to UTF-8 bytes, slice on a byte
   boundary, then walk over UTF-8 continuation bytes (10xxxxxx) so we
   never cut a multi-byte sequence in half. The byte cap is now actually
   enforced as bytes.

5. src/storage/trigger-store.ts:282 + supervisor — updateTrigger only
   marks the store dirty (10s autosave window). For terminal status
   transitions (errored/cancelled/timed_out/fired) a crash inside that
   window would leave on-disk status as "running", which loadTriggers()
   then misclassifies as "terminated by previous restart". Added
   persistNow() calls at the four supervisor transition sites:
   failTrigger, cancelTrigger, the hard-timeout handler, and the
   finalizeExit path. The on-disk store now always agrees with the
   in-memory state across terminal transitions.

6. src/core/gateway-actions.ts:trigger_create — always returned
   "Status: running" even when spawnTrigger() failed without throwing
   (unsupported language slipping past validation, child.pid undefined,
   failTrigger() routing). With #5 above, the store now reflects the
   real state synchronously, so we re-read getTrigger(id) after spawn
   and return ok:false + lastError when it landed in "errored", or
   surface the actual status string otherwise. Callers no longer get
   false success responses.

Regression tests (triggers-extended.test.ts):
- mid-run prompt asserts "Status: signalled" appears and "Status: fired"
  does NOT, on a still-running trigger. Covers fix #3.
- multi-byte UTF-8 payload (2000× 💧 = 8000 bytes vs 4000 string length)
  asserts the prompt's byte length stays bounded AND contains no
  Unicode replacement character (U+FFFD), which would indicate a
  mid-codepoint split. Covers fix #4.

The remaining Copilot thread (introducing a dedicated non-terminal
status enum value, replacing the TriggerStatus|"signalled" display-
status hack) is a wider type change with API/store implications;
better tackled in a follow-up than bundled into a defensive sweep.

Verification:
- npx vitest run → 1747/1748 pass (the 1 failure is the pre-existing
  package.functional "Stopped" expectation that fails whenever a live
  Talon daemon is running on the host — same env-dependent failure
  noted in PR #144 and PR #90's verification blocks).
- npx vitest run src/__tests__/triggers* src/__tests__/trigger-store* →
  74/74 pass (35 in triggers-extended including 2 new, 22 in
  trigger-store, 17 in triggers).
- npx tsc --noEmit → clean.
- npx prettier --check on all 4 changed files → clean.
- npm run lint → 0 errors in changed files (10 pre-existing warnings
  elsewhere unchanged).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@dylanneve1
dylanneve1 enabled auto-merge (squash) May 13, 2026 13:09
@dylanneve1
dylanneve1 merged commit cafc8d4 into main May 13, 2026
22 checks passed
claudiusthebot added a commit that referenced this pull request May 15, 2026
The README hadn't kept pace with what landed across PRs #96, #160, #161,
#165, #169, #170, and #172:

- Kilo and OpenCode backends were missing or misrepresented (the badge
  still said "Claude Agent SDK", the backend config row listed only
  claude/opencode, the architecture tree didn't mention kilo,
  remote-server, or shared).
- Discord frontend (PR #160) was absent from every list.
- Triggers (PR #96) were absent from the features table.
- Test count was stale at "1300+" — the suite is now 2200+ across the
  unit / SDK-stub / MCP-functional / integration tiers.
- Prerequisites assumed a single backend (Claude CLI on PATH).

Changes:

- New "Backends" section explaining the three options + their transport
  shape + shared remote-server infrastructure.
- Backends badge replaces the Claude Agent SDK badge.
- Features table: dedicated "Pluggable backend" row, new "Triggers"
  row, MCP tools row mentions triggers.
- Architecture tree refreshed: backend/registry.ts, backend/shared/,
  backend/remote-server/, kilo/, plus discord/ under frontend.
- Backend-specific prerequisites called out under Quick Start.
- Dependency rule paragraph mentions the QueryBackend interface.
- Config table: backend accepts claude/kilo/opencode, frontend accepts
  discord, model description is backend-agnostic.
- Development: test count updated to 2200+ across the tier matrix,
  added `npm run format`.
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.

3 participants