Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/tui-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: TUI Tests

on:
push:
branches: [main]
paths:
- "tui/**"
- ".github/workflows/tui-tests.yml"
pull_request:
paths:
- "tui/**"
- ".github/workflows/tui-tests.yml"

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install uv
uses: astral-sh/setup-uv@v4

- name: Install dependencies
working-directory: tui
run: uv pip install --system -e ".[dev]"

- name: Run TUI tests
working-directory: tui
run: python -m pytest tests/ -v

- name: Upload snapshot diffs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: snapshot-diffs-py${{ matrix.python-version }}
path: tui/tests/**/snapshot_tests_output/
retention-days: 7
Comment on lines +1 to +45

@coderabbitai coderabbitai Bot Mar 6, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

LGTM!

The workflow is well-structured with appropriate Python version matrix, uv for fast dependency installation, and helpful artifact upload for debugging snapshot failures.

Consider adding a job-level timeout and workflow_dispatch for manual triggering:

♻️ Optional enhancement
 jobs:
   test:
     runs-on: ubuntu-latest
+    timeout-minutes: 15
     strategy:
+      fail-fast: false
       matrix:
         python-version: ["3.11", "3.12", "3.13"]

And optionally:

 on:
   push:
     branches: [main]
     paths:
       - "tui/**"
       - ".github/workflows/tui-tests.yml"
   pull_request:
     paths:
       - "tui/**"
       - ".github/workflows/tui-tests.yml"
+  workflow_dispatch:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/tui-tests.yml around lines 1 - 45, Add a manual trigger
and a per-job timeout to the GitHub Actions workflow: add the top-level
workflow_dispatch event alongside the existing on: push/pull_request
configuration to allow manual runs, and add a timeout-minutes key under the test
job (job name "test") to enforce a job-level timeout (e.g., 30 or 60 minutes) so
long-running runs are automatically cancelled; update the YAML to include these
keys while keeping the existing matrix and steps intact.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good suggestions! Will consider adding workflow_dispatch and timeout-minutes in a follow-up. Low priority for now.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@vishnujayvel, sounds good! No rush — the workflow is solid as-is for the initial snapshot testing setup. Feel free to ping me when you revisit it in a follow-up PR.

1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export default defineConfig({
text: 'About',
items: [
{ text: 'Architecture', link: '/architecture' },
{ text: 'Tech Stack', link: '/tech-stack' },
{ text: 'Philosophy', link: '/philosophy' }
]
}
Expand Down
58 changes: 47 additions & 11 deletions docs/philosophy.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,63 @@
# Philosophy

hookwise was born from watching Claude Code do amazing things -- and occasionally terrifying things.
hookwise is a platform for guarding, coaching, and observing Claude Code sessions.

## Why hookwise Exists

I built hookwise because I kept waking up to disasters.
Claude Code is extraordinary. It can refactor an entire module, set up infrastructure, write tests -- all while you are reading the previous diff. That power is exactly why it needs guard rails, observability, and coaching.

Not "my server is down" disasters -- "my AI rewrote the build system while I was making coffee" disasters. Claude Code is extraordinary. It can refactor an entire module, set up infrastructure, write tests -- all while you are reading the previous diff. That power is exactly why it needs guard rails.
The existing hooks system is powerful but raw. You write bash scripts, scatter them across your config, and hope they cover the right events. There is no testing, no sharing, no analytics, and no way to know if your guards actually work until something slips through.

The existing hooks system is powerful but raw. You write bash scripts, scatter them across your config, and hope they cover the right events. There is no testing, no sharing, and no way to know if your guards actually work until something slips through.
hookwise replaces all of that with a platform: declarative guards, analytics that tell you where your time actually goes, coaching that nudges you when you have been in tooling mode for 90 minutes, feeds that surface context in your status line, recipes that let you share configurations, and a TUI that ties it all together.

**hookwise is one YAML file that replaces all of that.** Declarative guards that read like firewall rules. Analytics that tell you where your time actually goes. Coaching that nudges you when you have been in "tooling mode" for 90 minutes and forgot your original goal.
## Core Principles

## Design Principle
### 1. Platform, Not Just Config

The design principle is simple: **your AI should never be the reason you cannot sleep.** Everything in hookwise serves that principle -- guards protect, context enriches, side effects observe. If any part of hookwise itself errors, it fails open. hookwise must never be the thing that breaks your flow.
hookwise started as a YAML config layer for guard rails. It is now a platform with a SQLite analytics engine, a cache bus, coaching state, a feed daemon, a TUI, and a recipe system. YAML remains the right format for declaring guards, coaching rules, and feed settings -- the things that should be human-readable, git-tracked, and declarative. But YAML is not the whole platform.

> *"Guard rails should be boring. The exciting part is what you build when you are not worried about what your AI is doing."*
The platform needs a data layer, not just a config layer. Recipes need a standard way to persist structured data. Analytics needs a durable store. Coaching needs memory across sessions. Without a standard data layer, every recipe author invents their own storage, every new feature adds another ad hoc file, and the platform fragments.

## Fail-Open
hookwise should provide a shared data layer that any subsystem or recipe can use. The right tool for that layer -- SQLite, a managed database, purpose-built files -- is an engineering question, not a philosophical one.

If any part of hookwise itself errors, it fails open. hookwise must never accidentally block a tool call due to internal errors. Any unhandled exception anywhere in the dispatch pipeline results in `exit 0`.
### 2. Degrade Gracefully, Always Warn

If any part of hookwise errors, the session continues. hookwise must never accidentally block a tool call due to internal errors. Any unhandled exception in the dispatch pipeline results in `exit 0`.

But graceful degradation must not mean silent degradation. The v1.3 retro showed that "fail-open" was interpreted as "fail-silent" -- 5 of 7 launch bugs shipped unnoticed because errors were swallowed without any signal to the user. Every degradation must produce a visible diagnostic: a status line warning, a cache bus key, a log entry. The user should always know when something is running in a reduced state.

**Degrade gracefully AND always warn the user.**

Graceful degradation is a design practice, not a philosophical argument against infrastructure. A database server that crashes should not take down your Claude Code session -- but the possibility of a crash is not a reason to avoid running a database server. The question is whether the infrastructure solves a real problem and whether hookwise handles its failure modes correctly.

### 3. Infrastructure Is Normal

hookwise is a developer tool platform. Developer tool platforms have databases, background daemons, and managed processes. This is normal and expected.

hookwise already runs a background daemon for feeds, manages PID files, bundles better-sqlite3 as a native addon, and ships a Python TUI package. The bar for adding infrastructure should be: "Does this solve a real problem for users?" Not: "Is this zero-config?" and not: "Could this possibly go down?"

What matters is that infrastructure is invisible to the user when it works, and diagnostic when it does not.

### 4. Guards Should Be Boring; the Platform Should Not

Guard rails should be declarative, predictable, and unexciting. You write YAML rules that read like firewall policies. First block wins. No magic. The exciting part is what you build when you are not worried about what your AI is doing.

This principle applies specifically to the guard system. Other parts of hookwise -- analytics dashboards, coaching nudges, feed-backed status lines, the TUI, recipe ecosystems -- can and should be rich, interactive, and engaging. The platform grows; guards stay boring.

### 5. Recipes Need a Data Contract

A recipe is a shareable hookwise configuration: guards, coaching rules, feed producers, status line segments. Today, a recipe is a directory with `hooks.yaml`. That is enough for declarative configuration but not enough for recipes that need to persist data.

A recipe that tracks code review patterns needs a table. A recipe that scores commit quality needs a rolling window of scores. A recipe that detects dependency drift needs a snapshot of the last lockfile. Without a standard data layer, each recipe author builds their own persistence -- different formats, different locations, different cleanup strategies.

hookwise should define a data contract for recipes: a standard way to create tables, read/write rows, and query data. The underlying engine is an implementation detail. The contract is what makes recipes portable and composable.

### 6. Composition Over Extension

New capabilities should be composable from existing subsystems. A new feed producer is a function. A new guard is a YAML rule. A new recipe is a directory with `hooks.yaml` and optionally a data schema. A new status line segment is a pure function of cache and config.

When composition is not enough and a new architectural layer is needed, that is a signal to design it carefully -- not a signal to avoid it.

---

[Back to Home](/)
<- [Back to Home](/)
113 changes: 113 additions & 0 deletions hooks/agent-tracker.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# hooks/agent-tracker.sh
#
# Claude Code hook for SubagentStart / SubagentStop events.
# Reads JSON from stdin, maintains state at ~/.hookwise/cache/active-agents.json.
# Always exits 0 (fail-open).
set -uo pipefail

STATE_DIR="${HOOKWISE_STATE_DIR:-$HOME/.hookwise}"
CACHE_DIR="$STATE_DIR/cache"
STATE_FILE="$CACHE_DIR/active-agents.json"
STALE_SECONDS=600 # 10 minutes

mkdir -p "$CACHE_DIR"

# Read stdin JSON
INPUT=$(cat)

# Extract event type from CLAUDE_CODE_HOOK_EVENT_NAME env var
EVENT="${CLAUDE_CODE_HOOK_EVENT_NAME:-}"

# Parse fields from stdin JSON
agent_id=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('agent_id',''))" 2>/dev/null || echo "")
worktree=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('cwd',''))" 2>/dev/null || echo "")
team_name=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('team_name',''))" 2>/dev/null || echo "")
strategy=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('strategy',''))" 2>/dev/null || echo "")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +22 to +26

@coderabbitai coderabbitai Bot Mar 6, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consolidate JSON parsing into a single Python invocation.

Four separate python3 processes are spawned to parse the same JSON, each re-parsing from scratch. This adds unnecessary overhead on every hook invocation.

♻️ Proposed consolidation
 # Parse fields from stdin JSON
-agent_id=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('agent_id',''))" 2>/dev/null || echo "")
-worktree=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('cwd',''))" 2>/dev/null || echo "")
-team_name=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('team_name',''))" 2>/dev/null || echo "")
-strategy=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('strategy',''))" 2>/dev/null || echo "")
+read -r agent_id worktree team_name strategy < <(echo "$INPUT" | python3 -c "
+import sys, json
+d = json.load(sys.stdin)
+print(d.get('agent_id', ''))
+print(d.get('cwd', ''))
+print(d.get('team_name', ''))
+print(d.get('strategy', ''))
+" 2>/dev/null | tr '\n' ' ' || echo "   ")

Alternatively, use a here-string with tab-separated output for cleaner parsing:

# Parse all fields in one python3 call
eval "$(echo "$INPUT" | python3 -c "
import sys, json, shlex
d = json.load(sys.stdin)
print(f'agent_id={shlex.quote(d.get(\"agent_id\", \"\"))}')
print(f'worktree={shlex.quote(d.get(\"cwd\", \"\"))}')
print(f'team_name={shlex.quote(d.get(\"team_name\", \"\"))}')
print(f'strategy={shlex.quote(d.get(\"strategy\", \"\"))}')
" 2>/dev/null)" || { agent_id=""; worktree=""; team_name=""; strategy=""; }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hooks/agent-tracker.sh` around lines 22 - 26, Replace the four separate
python3 invocations that parse INPUT for agent_id, worktree, team_name, and
strategy with a single python3 call that reads the JSON once and emits
shell-safe assignments (use shlex.quote) for agent_id, worktree, team_name, and
strategy, then eval that output to populate the variables; ensure stderr is
redirected (2>/dev/null) and provide a fallback to set agent_id="", worktree="",
team_name="", strategy="" if the python call fails so behavior matches the
existing fallback logic.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good suggestion. The four python3 invocations are indeed redundant. A single python3 -c that parses the JSON once and emits all fields (e.g., tab-separated or as shell eval-able output) would be more efficient. Will consolidate in a follow-up — keeping the current working version for now since hook invocation overhead is dominated by the Python startup cost anyway (one vs four invocations is ~80ms difference).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@vishnujayvel, that's a fair call — the Python startup cost does dominate here, so the marginal gain from consolidating 4 → 1 invocation is modest in practice. Acknowledged, and no action needed on this PR.

Would you like me to open a follow-up GitHub issue to track the consolidation so it doesn't get lost?


✏️ Learnings added
Learnt from: vishnujayvel
Repo: vishnujayvel/hookwise PR: 18
File: hooks/agent-tracker.sh:22-26
Timestamp: 2026-03-06T05:48:14.680Z
Learning: In `hooks/agent-tracker.sh` (vishnujayvel/hookwise), the four separate `python3` invocations to parse `agent_id`, `worktree`, `team_name`, and `strategy` from stdin JSON are intentionally left as-is for the current working version. The owner plans to consolidate them into a single `python3 -c` call (emitting tab-separated or `eval`-able output) in a follow-up PR. The rationale is that hook invocation overhead is dominated by Python startup cost, making 1 vs 4 invocations only ~80ms difference.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: vishnujayvel
Repo: vishnujayvel/hookwise PR: 16
File: src/core/status-line/segments.ts:539-544
Timestamp: 2026-03-05T22:42:12.628Z
Learning: In hookwise, agents in src/core/status-line/segments.ts do not have heartbeats — they use a start/stop event model (SubagentStart/SubagentStop). The 10-minute stale threshold on `started_at` is intentional to catch orphaned entries where SubagentStop was never received (e.g., crash). The agent-tracker hook (hooks/agent-tracker.sh) cleans entries on every invocation, and long-running active agents have their `started_at` refreshed on re-dispatch.

Learnt from: vishnujayvel
Repo: vishnujayvel/hookwise PR: 18
File: tui/tests/test_snapshots.py:63-77
Timestamp: 2026-03-06T05:43:17.362Z
Learning: In `tui/tests/test_snapshots.py` (vishnujayvel/hookwise), the post-toggle Coaching snapshot test (verifying FeatureCard CSS class update after Switch toggle) is intentionally deferred to a follow-up PR. It should be implemented as a dedicated interaction test (e.g., `test_coaching_toggle.py`), not by extending the parametrized `test_tab_snapshot` suite, because it requires mounting the Coaching tab, simulating a Switch press, awaiting config write + CSS class update settlement, then capturing a second golden snapshot.


if [ -z "$agent_id" ]; then
exit 0
fi

# Derive a short name from worktree basename or truncated agent_id
if [ -n "$worktree" ]; then
name=$(basename "$worktree")
else
name="${agent_id:0:12}"
fi

NOW=$(date +%s)
LOCKFILE="$CACHE_DIR/.active-agents.lock"

# Use flock to prevent race conditions from concurrent hook invocations.
# macOS: flock is available via Homebrew (util-linux) or we fall back to no-lock.
lock_and_update() {
# Read existing state (or start fresh)
if [ -f "$STATE_FILE" ]; then
CURRENT=$(cat "$STATE_FILE" 2>/dev/null || echo '{"agents":[],"team_name":"","strategy":""}')
else
CURRENT='{"agents":[],"team_name":"","strategy":""}'
fi

# Use python3 for JSON manipulation (available on macOS)
# Pass variables via environment to avoid shell interpolation issues in Python
UPDATED=$(CURRENT="$CURRENT" EVENT="$EVENT" AGENT_ID="$agent_id" NAME="$name" \
TEAM_NAME="$team_name" STRATEGY="$strategy" NOW="$NOW" STALE="$STALE_SECONDS" \
python3 -c "
import json, os

current = json.loads(os.environ.get('CURRENT', '{}'))
agents = current.get('agents', [])
event = os.environ.get('EVENT', '')
agent_id = os.environ.get('AGENT_ID', '')
name = os.environ.get('NAME', '')
now = int(os.environ.get('NOW', '0'))
stale = int(os.environ.get('STALE', '600'))
team_name = os.environ.get('TEAM_NAME', '') or current.get('team_name', '')
strategy = os.environ.get('STRATEGY', '') or current.get('strategy', '')

# Clean stale entries (older than 10 minutes)
agents = [a for a in agents if (now - a.get('started_at', now)) < stale]

if event == 'SubagentStart':
# Remove existing entry with same agent_id (if re-started)
agents = [a for a in agents if a.get('agent_id') != agent_id]
agents.append({
'agent_id': agent_id,
'name': name,
'status': 'working',
'started_at': now
})
elif event == 'SubagentStop':
for a in agents:
if a.get('agent_id') == agent_id:
a['status'] = 'done'
a['stopped_at'] = now
break

result = {
'agents': agents,
'team_name': team_name,
'strategy': strategy,
'updated_at': now
}
print(json.dumps(result, indent=2))
" 2>/dev/null) || return 0

# Atomic write via temp file + rename
TMPFILE="$CACHE_DIR/.active-agents.tmp.$$"
echo "$UPDATED" > "$TMPFILE"
mv "$TMPFILE" "$STATE_FILE"
}

# Try flock if available; fall back to unguarded update
if command -v flock >/dev/null 2>&1; then
(
flock -w 5 200 || exit 0
lock_and_update
) 200>"$LOCKFILE"
else
lock_and_update
fi

exit 0
2 changes: 1 addition & 1 deletion src/cli/commands/stats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function loadStats(
agents: null,
costState: null,
streaks: null,
error: `No analytics database found. Run "hookwise init --preset analytics" to enable, then use Claude Code to generate data.`,
error: `No analytics database found. Analytics is enabled by default — use Claude Code to generate data. If disabled, run "hookwise init --preset analytics" to re-enable.`,
};
}

Expand Down
11 changes: 10 additions & 1 deletion src/cli/commands/status-line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@
*/

import { readFileSync } from "node:fs";
import { join } from "node:path";
import { safeReadJSON, atomicWriteJSON } from "../../core/state.js";
import { DEFAULT_CACHE_PATH } from "../../core/constants.js";
import { DEFAULT_CACHE_PATH, DEFAULT_STATE_DIR } from "../../core/constants.js";
import { renderTwoTier, DEFAULT_TWO_TIER_CONFIG } from "../../core/status-line/two-tier.js";

const ACTIVE_AGENTS_PATH = join(DEFAULT_STATE_DIR, "cache", "active-agents.json");

/**
* Stdin data shape from Claude Code's status line protocol.
*/
Expand Down Expand Up @@ -74,6 +77,12 @@ export async function runStatusLineCommand(): Promise<void> {
};
}

// Load active agents data for the agents segment
const agentsData = safeReadJSON<Record<string, unknown>>(ACTIVE_AGENTS_PATH, {});
if (agentsData && Object.keys(agentsData).length > 0) {
cache.agents = agentsData;
}

// Advance rotation index for line 2 cycling
const prevIndex = (cache._rotation_index as number) ?? 0;
const nextIndex = prevIndex + 1;
Expand Down
3 changes: 3 additions & 0 deletions src/core/analytics/authorship.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
/**
* @experimental This module is not part of the public API and should not
* be used in production. It may change or be removed without notice.
*
* Authorship Ledger with AI Confidence Scoring for hookwise analytics.
*
* Implements heuristic-based AI confidence scoring that estimates
Expand Down
2 changes: 1 addition & 1 deletion src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export function getDefaultConfig(): HooksConfig {
tone: "gentle",
},
},
analytics: { enabled: false },
analytics: { enabled: true },
greeting: { enabled: false },
sounds: { enabled: false },
statusLine: {
Expand Down
2 changes: 1 addition & 1 deletion src/core/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ export function dispatch(
// Feed platform: write heartbeat + CWD for daemon consumption (on every dispatch)
try {
const cachePath = config.statusLine.cachePath;
mergeKey(cachePath, "_heartbeat", { value: Date.now() }, 999999);
mergeKey(cachePath, "_dispatch_heartbeat", { value: Date.now() }, 999999);
mergeKey(cachePath, "_cwd", { value: process.cwd() }, 999999);
} catch {
// Fail-open: feed cache write errors must never affect dispatch result
Expand Down
5 changes: 4 additions & 1 deletion src/core/feeds/daemon-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,13 +272,16 @@ function buildFeedHealth(
): FeedHealth[] {
const health: FeedHealth[] = [];

// Built-in feeds
// Built-in feeds (all 8)
const builtinFeeds = [
{ name: "pulse", enabled: feedsConfig.pulse.enabled, intervalSeconds: feedsConfig.pulse.intervalSeconds },
{ name: "project", enabled: feedsConfig.project.enabled, intervalSeconds: feedsConfig.project.intervalSeconds },
{ name: "calendar", enabled: feedsConfig.calendar.enabled, intervalSeconds: feedsConfig.calendar.intervalSeconds },
{ name: "news", enabled: feedsConfig.news.enabled, intervalSeconds: feedsConfig.news.intervalSeconds },
{ name: "insights", enabled: feedsConfig.insights.enabled, intervalSeconds: feedsConfig.insights.intervalSeconds },
{ name: "practice", enabled: feedsConfig.practice.enabled, intervalSeconds: feedsConfig.practice.intervalSeconds },
{ name: "weather", enabled: feedsConfig.weather.enabled, intervalSeconds: feedsConfig.weather.intervalSeconds },
{ name: "memories", enabled: feedsConfig.memories.enabled, intervalSeconds: feedsConfig.memories.intervalSeconds },
];

for (const feed of builtinFeeds) {
Expand Down
35 changes: 29 additions & 6 deletions src/core/feeds/daemon-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,11 @@ const MAX_LOG_LINES = 1000;
/** Lines to keep after rotation. */
const KEEP_LOG_LINES = 500;

/** Heartbeat TTL: effectively infinite (used as a sentinel, not for freshness). */
const HEARTBEAT_TTL = 999999;
/** Daemon heartbeat TTL: 90 seconds (refreshed every 30s, stale = daemon crashed). */
const DAEMON_HEARTBEAT_TTL = 90;

/** Daemon heartbeat interval: 30 seconds. */
const DAEMON_HEARTBEAT_INTERVAL_MS = 30_000;

// --- Logging ---

Expand Down Expand Up @@ -253,8 +256,23 @@ export async function runDaemon(projectDir: string): Promise<void> {
writeFileSync(pidPath, String(process.pid), "utf-8");
daemonLog(`PID ${process.pid} written to ${pidPath}`, logFile);

// Step 5 (partial): Write initial heartbeat
mergeKey(cachePath, "_heartbeat", { value: Date.now() }, HEARTBEAT_TTL);
// Step 5 (partial): Write initial daemon heartbeat + start refresh interval
try {
mergeKey(cachePath, "_daemon_heartbeat", { value: Date.now() }, DAEMON_HEARTBEAT_TTL);
} catch (error) {
daemonLog(
`Initial daemon heartbeat write failed: ${error instanceof Error ? error.message : String(error)}`,
logFile,
);
}

const heartbeatTimer = setInterval(() => {
try {
mergeKey(cachePath, "_daemon_heartbeat", { value: Date.now() }, DAEMON_HEARTBEAT_TTL);
} catch {
// Fail-open: heartbeat write errors must never crash daemon
}
}, DAEMON_HEARTBEAT_INTERVAL_MS);

// Step 4: Start staggered intervals for enabled feeds
const enabledFeeds = registry.getEnabled();
Expand All @@ -281,14 +299,19 @@ export async function runDaemon(projectDir: string): Promise<void> {
intervalTimers.push(initialTimeout);
}

intervalTimers.push(heartbeatTimer);

daemonLog(`Started ${enabledFeeds.length} feed(s) with staggered intervals`, logFile);

// Step 5: Inactivity monitoring
const inactivityTimer = setInterval(() => {
try {
const parsed = readAll(cachePath);
const heartbeat = (parsed?._heartbeat as Record<string, unknown>)?.value as number | undefined;

const rawHeartbeat = (parsed?._dispatch_heartbeat as Record<string, unknown>)?.value;
const heartbeat =
typeof rawHeartbeat === "number" && Number.isFinite(rawHeartbeat)
? rawHeartbeat
: undefined;
const referenceTime = heartbeat ?? daemonStartTime;
const timeoutMs = config.daemon.inactivityTimeoutMinutes * 60 * 1000;

Expand Down
Loading