feat(triggers): self-authored watcher scripts that wake the bot - #96
Conversation
|
Rebased onto typecheck ✅ · format:check ✅ — CI running. |
|
🤖 Heartbeat #175 — branch coverage fix applied CI was failing with Added
All 14 tests pass locally; CI will confirm coverage ≥ 60%. |
|
Coverage fix — 1. Windows N22 test failure (
2. Ubuntu N22 coverage 59.67% < 60% (N24 was fine)
typecheck ✅ · format:check ✅ · 18 extended tests pass locally · CI running on |
|
Coverage fix round 3 — CATCH #21 (hb #179)
New head:
25 tests total, all passing locally. Pushing to CI. |
|
Coverage fix round 4 ( This PR is now MERGEABLE and CI-clean. Six code PRs are all ready in one pass:
Suggested merge order: |
There was a problem hiding this comment.
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.
| 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), | ||
| ); |
| const logStream = createWriteStream(trigger.logPath, { | ||
| flags: "a", | ||
| mode: 0o600, | ||
| }); |
| // Truncate payload — we don't want a runaway script blowing out the prompt | ||
| const trimmed = (payload ?? "").slice(0, FIRE_PAYLOAD_MAX_BYTES); | ||
|
|
| if (!t) return undefined; | ||
| Object.assign(t, updates); | ||
| dirty = true; |
| timeoutSeconds: number; | ||
| /** Exit code on terminal status. */ | ||
| exitCode?: number; | ||
| /** Number of mid-run TALON_FIRE: lines emitted. */ |
| text: | ||
| `Created trigger "${name}" (id: ${id})\n` + | ||
| `Language: ${lang}\n` + | ||
| `Timeout: ${timeoutSeconds}s\n` + | ||
| `Status: running`, |
… 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>
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>
… 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>
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>
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`.
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:
gh pr view, exit 0 on merge.TALON_FIRE:on every threshold crossing, never exits.curl --fail.The contract (the "standard")
Bot writes the script body. Talon spawns it under the right interpreter, captures stdout/stderr, watches for signals.
TALON_FIRE: <text>fires a wake-up immediately. Script keeps running. Use for watchers that emit multiple events.Wake-ups go through the existing
dispatcher.execute()withsource: "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 + spawntrigger_list— status, fire count, last error per trigger in this chattrigger_cancel(trigger_id)— SIGTERM (then SIGKILL after 5s)trigger_logs(trigger_id, lines?)— tail of stdout + stderrtrigger_delete(trigger_id)— cancel if running, then rm script + logArchitecture
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-basedTALON_FIRE:detection, hard timeout, SIGTERM/SIGKILL escalation.src/core/tools/triggers.ts— 5 tool definitions, newtriggerstag.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>.logfor 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
What this deliberately doesn't do
Test plan
🤖 Generated with Claude Code