@reticlehq/server 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +719 -0
- package/dist/bridge/auth-failure-reason.d.ts +19 -0
- package/dist/bridge/auth-failure-reason.js +29 -0
- package/dist/{bridge-security.d.ts → bridge/bridge-security.d.ts} +1 -1
- package/dist/{bridge-security.js → bridge/bridge-security.js} +1 -1
- package/dist/{bridge.d.ts → bridge/bridge.d.ts} +13 -1
- package/dist/{bridge.js → bridge/bridge.js} +95 -15
- package/dist/{bridge.test-harness.d.ts → bridge/bridge.test-harness.d.ts} +9 -11
- package/dist/{bridge.test-harness.js → bridge/bridge.test-harness.js} +18 -10
- package/dist/{pairing-token.js → bridge/pairing-token.js} +1 -1
- package/dist/capsule/capsule-store.d.ts +8 -3
- package/dist/capsule/capsule-store.js +7 -2
- package/dist/capsule/capsule.js +3 -3
- package/dist/capsule/causal-summary.d.ts +8 -0
- package/dist/capsule/causal-summary.js +22 -12
- package/dist/capsule/divergence.d.ts +9 -4
- package/dist/capsule/divergence.js +8 -8
- package/dist/capsule/predicate-to-links.js +11 -8
- package/dist/cli/cli-doctor.d.ts +1 -0
- package/dist/cli/cli-doctor.js +135 -0
- package/dist/{cli-flow-commands.d.ts → cli/cli-flow-commands.d.ts} +2 -2
- package/dist/{cli-flow-commands.js → cli/cli-flow-commands.js} +15 -15
- package/dist/{cli-launch.d.ts → cli/cli-launch.d.ts} +32 -3
- package/dist/cli/cli-launch.js +171 -0
- package/dist/cli/cli-parse-feedback.d.ts +31 -0
- package/dist/cli/cli-parse-feedback.js +56 -0
- package/dist/{cli-parse.d.ts → cli/cli-parse.d.ts} +6 -19
- package/dist/{cli-parse.js → cli/cli-parse.js} +118 -81
- package/dist/{cli-port.d.ts → cli/cli-port.d.ts} +12 -0
- package/dist/{cli-port.js → cli/cli-port.js} +9 -9
- package/dist/cli/cli-update-commands.d.ts +4 -0
- package/dist/cli/cli-update-commands.js +93 -0
- package/dist/{cli-verify.js → cli/cli-verify.js} +14 -14
- package/dist/{cloud-cli.js → cli/cloud-cli.js} +29 -24
- package/dist/cli/doctor-daemon-line.d.ts +37 -0
- package/dist/cli/doctor-daemon-line.js +35 -0
- package/dist/cli/port-holder.d.ts +43 -0
- package/dist/cli/port-holder.js +99 -0
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +302 -100
- package/dist/cloud/cloud-config.d.ts +0 -1
- package/dist/cloud/cloud-config.js +9 -9
- package/dist/cloud/cloud-sync.d.ts +1 -7
- package/dist/cloud/cloud-sync.js +12 -12
- package/dist/crawl/crawl-empty.d.ts +16 -0
- package/dist/crawl/crawl-empty.js +24 -0
- package/dist/crawl/crawl-tools.js +4 -1
- package/dist/crawl/crawl.js +26 -7
- package/dist/daemon/daemon-resilience.d.ts +73 -0
- package/dist/daemon/daemon-resilience.js +279 -0
- package/dist/daemon/daemon-usefulness.d.ts +61 -0
- package/dist/daemon/daemon-usefulness.js +62 -0
- package/dist/{daemon.d.ts → daemon/daemon.d.ts} +33 -8
- package/dist/{daemon.js → daemon/daemon.js} +105 -48
- package/dist/daemon/heartbeat.d.ts +115 -0
- package/dist/daemon/heartbeat.js +141 -0
- package/dist/daemon/idle-grace.d.ts +32 -0
- package/dist/daemon/idle-grace.js +41 -0
- package/dist/{idle-shutdown.d.ts → daemon/idle-shutdown.d.ts} +23 -2
- package/dist/{idle-shutdown.js → daemon/idle-shutdown.js} +37 -6
- package/dist/daemon/port-presence.d.ts +76 -0
- package/dist/daemon/port-presence.js +93 -0
- package/dist/daemon/state-dir.d.ts +7 -0
- package/dist/daemon/state-dir.js +59 -0
- package/dist/daemon/unclean-predecessor.d.ts +23 -0
- package/dist/daemon/unclean-predecessor.js +27 -0
- package/dist/domain/domain-model.js +2 -2
- package/dist/events/body-failures.js +8 -8
- package/dist/events/contradictions.d.ts +1 -1
- package/dist/events/contradictions.js +60 -10
- package/dist/events/echo-mismatch.js +10 -10
- package/dist/events/event-filters.js +6 -6
- package/dist/events/json-salvage.d.ts +2 -1
- package/dist/events/json-salvage.js +6 -6
- package/dist/events/observed-in-window.d.ts +12 -0
- package/dist/events/observed-in-window.js +21 -0
- package/dist/events/predicate-eval.d.ts +53 -20
- package/dist/events/predicate-eval.js +179 -55
- package/dist/events/predicate-parse.d.ts +23 -0
- package/dist/events/predicate-parse.js +54 -0
- package/dist/events/predicate.d.ts +7 -2
- package/dist/events/predicate.js +126 -35
- package/dist/events/reconcile.js +5 -5
- package/dist/events/ring-buffer.js +2 -17
- package/dist/events/stale-response.js +6 -6
- package/dist/events/testid-near-miss.d.ts +22 -0
- package/dist/events/testid-near-miss.js +33 -0
- package/dist/events/unit-mismatch.js +8 -8
- package/dist/flows/affected-tools.js +19 -4
- package/dist/flows/affected.js +1 -1
- package/dist/flows/annotate-target.d.ts +1 -0
- package/dist/flows/annotate-target.js +20 -0
- package/dist/flows/annotate-tools.js +25 -3
- package/dist/flows/annotate.js +17 -4
- package/dist/flows/assertion-integrity.d.ts +2 -1
- package/dist/flows/assertion-tiers-store.d.ts +3 -66
- package/dist/flows/assertion-tiers-store.js +2 -2
- package/dist/flows/attributed-failure.d.ts +17 -0
- package/dist/flows/attributed-failure.js +20 -0
- package/dist/flows/buddy-status.d.ts +2 -1
- package/dist/flows/buddy-status.js +2 -2
- package/dist/flows/coverage.d.ts +3 -2
- package/dist/flows/coverage.js +2 -2
- package/dist/flows/decision.js +24 -9
- package/dist/flows/empty-flow.d.ts +6 -0
- package/dist/flows/empty-flow.js +24 -0
- package/dist/flows/flake-store.d.ts +0 -1
- package/dist/flows/flake-store.js +1 -1
- package/dist/flows/flake.d.ts +2 -1
- package/dist/flows/flake.js +2 -2
- package/dist/flows/flow-classify.js +12 -0
- package/dist/flows/flow-replay-run.js +4 -4
- package/dist/flows/flow-replay.d.ts +6 -8
- package/dist/flows/flow-replay.js +132 -48
- package/dist/flows/flow-report.js +1 -1
- package/dist/flows/flow-scope.d.ts +1 -6
- package/dist/flows/flow-step-runners.d.ts +26 -6
- package/dist/flows/flow-step-runners.js +156 -6
- package/dist/flows/flow-success.js +20 -15
- package/dist/flows/flow-tools.js +63 -21
- package/dist/flows/flows.js +15 -11
- package/dist/flows/gate.d.ts +4 -3
- package/dist/flows/gate.js +1 -1
- package/dist/flows/heal-run.d.ts +0 -8
- package/dist/flows/heal-run.js +5 -5
- package/dist/flows/heal.js +3 -3
- package/dist/flows/predicate-to-expect.d.ts +38 -0
- package/dist/flows/predicate-to-expect.js +95 -0
- package/dist/flows/replay.d.ts +16 -1
- package/dist/flows/replay.js +58 -22
- package/dist/flows/role-anchor-nearest.d.ts +6 -0
- package/dist/flows/role-anchor-nearest.js +45 -0
- package/dist/flows/role-drift-reason.d.ts +20 -0
- package/dist/flows/role-drift-reason.js +39 -0
- package/dist/flows/server-verify.d.ts +1 -1
- package/dist/flows/server-verify.js +5 -5
- package/dist/flows/suite-flakes.d.ts +33 -0
- package/dist/flows/suite-flakes.js +32 -0
- package/dist/flows/verify-change-tools.js +32 -10
- package/dist/flows/verify-change-verdict.d.ts +15 -0
- package/dist/flows/verify-change-verdict.js +25 -0
- package/dist/flows/watch-batcher.d.ts +3 -2
- package/dist/honesty/already-true.d.ts +2 -0
- package/dist/honesty/already-true.js +31 -0
- package/dist/honesty/blind-spots.d.ts +35 -3
- package/dist/honesty/blind-spots.js +57 -12
- package/dist/honesty/honesty.d.ts +3 -2
- package/dist/honesty/honesty.js +4 -4
- package/dist/honesty/uncaptured-bodies.js +1 -1
- package/dist/honesty/unread-outcome.js +2 -2
- package/dist/honesty/verified.d.ts +31 -3
- package/dist/honesty/verified.js +64 -8
- package/dist/http-server.js +23 -7
- package/dist/hunt/hunt-report.d.ts +2 -1
- package/dist/hunt/hunt-report.js +2 -2
- package/dist/index.d.ts +9 -6
- package/dist/index.js +41 -25
- package/dist/init/agent-rules.d.ts +20 -1
- package/dist/init/agent-rules.js +64 -8
- package/dist/init/app-choice.d.ts +20 -0
- package/dist/init/app-choice.js +25 -0
- package/dist/init/astro-host.d.ts +21 -0
- package/dist/init/astro-host.js +27 -0
- package/dist/init/astro-patch.d.ts +16 -0
- package/dist/init/astro-patch.js +240 -0
- package/dist/init/capabilities.js +4 -1
- package/dist/init/cra.d.ts +50 -0
- package/dist/init/cra.js +106 -0
- package/dist/init/cursor.js +39 -4
- package/dist/init/desktop-doctor.d.ts +3 -2
- package/dist/init/desktop-doctor.js +4 -4
- package/dist/init/detect.d.ts +9 -2
- package/dist/init/detect.js +24 -5
- package/dist/init/mcp-clients.d.ts +81 -0
- package/dist/init/mcp-clients.js +248 -0
- package/dist/init/mcp-registered.d.ts +12 -0
- package/dist/init/mcp-registered.js +14 -0
- package/dist/init/mcp.d.ts +27 -2
- package/dist/init/mcp.js +40 -4
- package/dist/init/next-patch.d.ts +2 -4
- package/dist/init/next-patch.js +109 -19
- package/dist/init/node-io.js +29 -6
- package/dist/init/plan-framework.d.ts +16 -1
- package/dist/init/plan-framework.js +166 -8
- package/dist/init/plan.d.ts +85 -6
- package/dist/init/plan.js +196 -33
- package/dist/init/project-id.d.ts +5 -9
- package/dist/init/project-id.js +10 -22
- package/dist/init/run.d.ts +23 -1
- package/dist/init/run.js +338 -47
- package/dist/init/slash-command.d.ts +9 -0
- package/dist/init/slash-command.js +10 -1
- package/dist/init/snippets.d.ts +7 -10
- package/dist/init/snippets.js +44 -15
- package/dist/init/vite-config.js +1 -1
- package/dist/init/windows-quote.d.ts +38 -0
- package/dist/init/windows-quote.js +70 -0
- package/dist/init/workspace-apps.d.ts +23 -0
- package/dist/init/workspace-apps.js +68 -0
- package/dist/input/driven-redaction.js +1 -1
- package/dist/input/network-detail.js +8 -8
- package/dist/input/network-mock-tools.js +2 -2
- package/dist/input/network-mock.js +4 -4
- package/dist/input/real-input.js +8 -3
- package/dist/input/scroll-find.js +2 -2
- package/dist/input/scroll-tools.d.ts +0 -5
- package/dist/input/scroll-tools.js +11 -4
- package/dist/input/viewport-tools.js +2 -2
- package/dist/journal/ambient-store.d.ts +0 -2
- package/dist/journal/ambient-store.js +1 -1
- package/dist/journal/ambient.js +1 -1
- package/dist/journal/attach-journal.d.ts +2 -1
- package/dist/journal/deviation-report.js +6 -6
- package/dist/journal/deviation-service.js +1 -1
- package/dist/journal/envelope-store.d.ts +0 -2
- package/dist/journal/envelope-store.js +1 -1
- package/dist/journal/envelope.d.ts +2 -3
- package/dist/journal/envelope.js +3 -3
- package/dist/journal/journal-query.js +1 -1
- package/dist/journal/journal-recorder.d.ts +2 -1
- package/dist/journal/journal-recorder.js +2 -2
- package/dist/journal/rollups.js +9 -9
- package/dist/journal/session-end.d.ts +2 -1
- package/dist/journal/session-journal.js +4 -4
- package/dist/license/license.d.ts +0 -14
- package/dist/license/license.js +8 -8
- package/dist/log.d.ts +10 -0
- package/dist/log.js +11 -1
- package/dist/mcp/mcp-is-error.d.ts +13 -0
- package/dist/mcp/mcp-is-error.js +18 -0
- package/dist/mcp/mcp-outage.d.ts +32 -0
- package/dist/mcp/mcp-outage.js +55 -0
- package/dist/mcp/mcp-proxy.d.ts +134 -0
- package/dist/mcp/mcp-proxy.js +720 -0
- package/dist/{mcp.d.ts → mcp/mcp.d.ts} +13 -10
- package/dist/{mcp.js → mcp/mcp.js} +139 -37
- package/dist/mcp/proxy-handshake.d.ts +3 -0
- package/dist/mcp/proxy-handshake.js +54 -0
- package/dist/mcp/proxy-lifecycle.d.ts +64 -0
- package/dist/mcp/proxy-lifecycle.js +70 -0
- package/dist/mcp/tool-catalog-cache.d.ts +29 -0
- package/dist/mcp/tool-catalog-cache.js +55 -0
- package/dist/oracles/propose-consequences.d.ts +2 -1
- package/dist/oracles/propose-consequences.js +16 -11
- package/dist/oracles/self-instrument.d.ts +2 -1
- package/dist/oracles/self-instrument.js +1 -1
- package/dist/phenomena/phenomena.js +3 -3
- package/dist/platform.d.ts +16 -0
- package/dist/platform.js +16 -0
- package/dist/pool/browser-pool.d.ts +9 -0
- package/dist/pool/browser-pool.js +38 -9
- package/dist/pool/playwright-launcher.d.ts +15 -2
- package/dist/pool/playwright-launcher.js +21 -2
- package/dist/project/fs-port.js +1 -1
- package/dist/project/project-tools.js +2 -2
- package/dist/runs/build-verification-run.js +1 -1
- package/dist/runs/repair-prompt.d.ts +0 -6
- package/dist/runs/repair-prompt.js +3 -3
- package/dist/runs/risk-classify.js +1 -1
- package/dist/runs/run-diff.d.ts +0 -2
- package/dist/runs/run-diff.js +3 -3
- package/dist/runs/run-tools.js +2 -2
- package/dist/runs/verification-sync.js +2 -2
- package/dist/runs/verify-http.js +1 -1
- package/dist/runs/verify-server.js +1 -1
- package/dist/session/attachment-history.d.ts +46 -0
- package/dist/session/attachment-history.js +65 -0
- package/dist/session/command-timeout.d.ts +11 -3
- package/dist/session/command-timeout.js +2 -2
- package/dist/session/control-envelope.d.ts +6 -0
- package/dist/session/control-envelope.js +7 -1
- package/dist/session/dev-server-probe.d.ts +13 -0
- package/dist/session/dev-server-probe.js +52 -0
- package/dist/session/last-act.d.ts +17 -3
- package/dist/session/last-act.js +16 -4
- package/dist/session/live-control-tools.js +7 -2
- package/dist/session/live-control.js +1 -1
- package/dist/session/looks-like-dev-server.d.ts +14 -0
- package/dist/session/looks-like-dev-server.js +25 -0
- package/dist/session/no-session-diagnosis.d.ts +45 -0
- package/dist/session/no-session-diagnosis.js +120 -0
- package/dist/session/no-session-watch.d.ts +21 -0
- package/dist/session/no-session-watch.js +69 -0
- package/dist/session/observed-state.d.ts +4 -0
- package/dist/session/observed-state.js +19 -1
- package/dist/session/output-budget.js +1 -1
- package/dist/session/pending-commands.js +1 -0
- package/dist/session/session-health.d.ts +8 -1
- package/dist/session/session-health.js +8 -7
- package/dist/session/session-info.d.ts +43 -8
- package/dist/session/session-info.js +46 -1
- package/dist/session/session-lease.d.ts +0 -11
- package/dist/session/session-lease.js +1 -1
- package/dist/session/session-manager.d.ts +6 -0
- package/dist/session/session-manager.js +124 -12
- package/dist/session/session-readiness.d.ts +1 -1
- package/dist/session/session-readiness.js +6 -1
- package/dist/session/session-reaper.js +1 -1
- package/dist/session/session-reconnect.d.ts +32 -0
- package/dist/session/session-reconnect.js +42 -0
- package/dist/session/session.d.ts +15 -2
- package/dist/session/session.js +77 -26
- package/dist/session/state-select.d.ts +1 -1
- package/dist/session/state-select.js +1 -1
- package/dist/status-payload.d.ts +19 -0
- package/dist/status-payload.js +11 -0
- package/dist/telemetry/argument-shape.js +10 -1
- package/dist/telemetry/browser-mode.d.ts +27 -0
- package/dist/telemetry/browser-mode.js +33 -0
- package/dist/telemetry/bug-found.d.ts +17 -21
- package/dist/telemetry/bug-found.js +120 -8
- package/dist/telemetry/cli-telemetry.d.ts +2 -12
- package/dist/telemetry/cli-telemetry.js +27 -4
- package/dist/telemetry/crash-cause.d.ts +47 -0
- package/dist/telemetry/crash-cause.js +104 -0
- package/dist/telemetry/daemon-telemetry.d.ts +38 -1
- package/dist/telemetry/daemon-telemetry.js +37 -12
- package/dist/telemetry/dev-repo.js +4 -4
- package/dist/telemetry/error-class.d.ts +26 -0
- package/dist/telemetry/error-class.js +52 -0
- package/dist/telemetry/error-fingerprint.js +21 -2
- package/dist/telemetry/feedback-cli.d.ts +8 -3
- package/dist/telemetry/feedback-cli.js +52 -8
- package/dist/telemetry/feedback-context.d.ts +23 -6
- package/dist/telemetry/feedback-context.js +105 -5
- package/dist/telemetry/feedback-delivery.d.ts +20 -0
- package/dist/telemetry/feedback-delivery.js +30 -0
- package/dist/telemetry/feedback-outbox.d.ts +22 -0
- package/dist/telemetry/feedback-outbox.js +87 -0
- package/dist/telemetry/feedback.d.ts +22 -1
- package/dist/telemetry/feedback.js +67 -8
- package/dist/telemetry/git-facts.d.ts +7 -0
- package/dist/telemetry/git-facts.js +5 -5
- package/dist/telemetry/project-profile.js +2 -1
- package/dist/telemetry/session-metrics.d.ts +47 -3
- package/dist/telemetry/session-metrics.js +315 -15
- package/dist/telemetry/telemetry.d.ts +45 -2
- package/dist/telemetry/telemetry.js +150 -37
- package/dist/telemetry/verification-of.d.ts +22 -0
- package/dist/telemetry/verification-of.js +84 -0
- package/dist/tools/act-capsule.js +2 -2
- package/dist/tools/act-danger.d.ts +12 -0
- package/dist/tools/act-danger.js +20 -3
- package/dist/tools/act-helpers.js +4 -4
- package/dist/tools/act-tools.d.ts +0 -8
- package/dist/tools/act-tools.js +114 -118
- package/dist/tools/act-view.js +7 -7
- package/dist/tools/alias-args.d.ts +17 -0
- package/dist/tools/alias-args.js +25 -0
- package/dist/tools/assert-grade.js +9 -9
- package/dist/tools/assert-verdict.d.ts +9 -1
- package/dist/tools/assert-verdict.js +21 -6
- package/dist/tools/browser-tools.js +36 -11
- package/dist/tools/contract-tools.d.ts +0 -5
- package/dist/tools/contract-tools.js +27 -1
- package/dist/tools/coverage-identity.d.ts +34 -0
- package/dist/tools/coverage-identity.js +46 -0
- package/dist/tools/coverage-tools.js +10 -14
- package/dist/tools/dynamic-tools.d.ts +2 -1
- package/dist/tools/dynamic-tools.js +96 -19
- package/dist/tools/error-recovery.d.ts +34 -2
- package/dist/tools/error-recovery.js +255 -4
- package/dist/tools/feedback-invite.d.ts +48 -0
- package/dist/tools/feedback-invite.js +71 -0
- package/dist/tools/feedback-tools.d.ts +12 -1
- package/dist/tools/feedback-tools.js +28 -5
- package/dist/tools/invoke-tool.d.ts +0 -12
- package/dist/tools/invoke-tool.js +126 -33
- package/dist/tools/lease-hint.d.ts +17 -0
- package/dist/tools/lease-hint.js +24 -0
- package/dist/tools/lease-tools.js +6 -5
- package/dist/tools/merge-tools.d.ts +10 -1
- package/dist/tools/merge-tools.js +9 -3
- package/dist/tools/merged-name-redirect.d.ts +13 -0
- package/dist/tools/merged-name-redirect.js +55 -0
- package/dist/tools/navigate-arrival.d.ts +26 -0
- package/dist/tools/navigate-arrival.js +60 -0
- package/dist/tools/navigate-result.d.ts +31 -0
- package/dist/tools/navigate-result.js +38 -0
- package/dist/tools/observe-tools.js +72 -20
- package/dist/tools/observed-nothing.d.ts +25 -0
- package/dist/tools/observed-nothing.js +33 -0
- package/dist/tools/query-paginate.js +2 -2
- package/dist/tools/query-shape.d.ts +18 -0
- package/dist/tools/query-shape.js +39 -0
- package/dist/tools/read-tools.js +81 -20
- package/dist/tools/real-input-attempt.d.ts +28 -0
- package/dist/tools/real-input-attempt.js +93 -0
- package/dist/tools/reconcile-tools.js +7 -7
- package/dist/tools/reload-result.d.ts +14 -0
- package/dist/tools/reload-result.js +29 -0
- package/dist/tools/snapshot-delta.d.ts +4 -5
- package/dist/tools/snapshot-delta.js +0 -0
- package/dist/tools/tool-kit.d.ts +16 -0
- package/dist/tools/tool-surface.d.ts +83 -0
- package/dist/tools/tool-surface.js +217 -0
- package/dist/tools/tools-helpers.js +6 -6
- package/dist/tools/tools.d.ts +15 -0
- package/dist/tools/tools.js +53 -17
- package/dist/tools/unadvertised-help.d.ts +5 -0
- package/dist/tools/unadvertised-help.js +39 -0
- package/dist/trace.d.ts +69 -0
- package/dist/trace.js +150 -0
- package/dist/update/nudge-credit.d.ts +4 -0
- package/dist/update/nudge-credit.js +53 -0
- package/dist/update/sdk-sync.d.ts +21 -0
- package/dist/update/sdk-sync.js +42 -0
- package/dist/update/update-checker.js +3 -3
- package/dist/update/update-nudge.d.ts +63 -6
- package/dist/update/update-nudge.js +128 -15
- package/dist/update/updater.d.ts +1 -9
- package/dist/update/updater.js +13 -8
- package/dist/version/peer-announce.d.ts +4 -0
- package/dist/version/peer-announce.js +30 -0
- package/dist/{server-version.js → version/server-version.js} +5 -1
- package/dist/version/version-nudge.d.ts +28 -0
- package/dist/version/version-nudge.js +40 -0
- package/dist/version/version-skew.d.ts +66 -0
- package/dist/version/version-skew.js +71 -0
- package/dist/visual/visual-diff.js +2 -2
- package/dist/visual/visual-tools.js +4 -4
- package/docs/README.md +55 -0
- package/docs/agent-cheatsheet.md +85 -0
- package/docs/architecture.md +113 -0
- package/docs/benchmarks.md +255 -0
- package/docs/debugging.md +102 -0
- package/docs/deploy-checks.md +69 -0
- package/docs/desktop-apps.md +256 -0
- package/docs/enterprise.md +62 -0
- package/docs/fixtures-dispatch-receiver.yml +76 -0
- package/docs/fixtures.md +45 -0
- package/docs/flows.md +166 -0
- package/docs/gate-plan.md +98 -0
- package/docs/gates.md +117 -0
- package/docs/getting-started.md +469 -0
- package/docs/human-control.md +63 -0
- package/docs/integration-patterns.md +171 -0
- package/docs/local-registry.md +126 -0
- package/docs/matrix/2.5.0/claude-code-machine.json +19 -0
- package/docs/matrix/2.5.0/codex-machine.json +20 -0
- package/docs/matrix/2.5.0/cursor-machine.json +22 -0
- package/docs/matrix/2.5.0/gemini-machine.json +22 -0
- package/docs/matrix/2.5.0/opencode-machine.json +22 -0
- package/docs/matrix/2.5.0/vscode-machine.json +22 -0
- package/docs/matrix/2.5.0/windsurf-machine.json +22 -0
- package/docs/matrix/MATRIX.md +26 -0
- package/docs/matrix/README.md +59 -0
- package/docs/mint.json +61 -0
- package/docs/multi-agent-testing.md +55 -0
- package/docs/platform-integration.md +122 -0
- package/docs/system-map.md +152 -0
- package/docs/telemetry-contract.md +214 -0
- package/docs/telemetry.md +191 -0
- package/docs/testing.md +82 -0
- package/docs/token-efficiency.md +80 -0
- package/docs/usage.md +1046 -0
- package/package.json +8 -5
- package/dist/cli-doctor.d.ts +0 -6
- package/dist/cli-doctor.js +0 -60
- package/dist/cli-launch.js +0 -112
- package/dist/daemon-resilience.d.ts +0 -22
- package/dist/daemon-resilience.js +0 -85
- package/dist/mcp-proxy.d.ts +0 -67
- package/dist/mcp-proxy.js +0 -337
- package/dist/tools/profiles.d.ts +0 -37
- package/dist/tools/profiles.js +0 -139
- /package/dist/{pairing-token.d.ts → bridge/pairing-token.d.ts} +0 -0
- /package/dist/{token-auth.d.ts → bridge/token-auth.d.ts} +0 -0
- /package/dist/{token-auth.js → bridge/token-auth.js} +0 -0
- /package/dist/{cli-verify.d.ts → cli/cli-verify.d.ts} +0 -0
- /package/dist/{cloud-cli.d.ts → cli/cloud-cli.d.ts} +0 -0
- /package/dist/{server-version.d.ts → version/server-version.d.ts} +0 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether this daemon has ever been useful to anyone.
|
|
3
|
+
*
|
|
4
|
+
* Reticle is usually registered as a GLOBAL MCP server, so every agent session in every directory
|
|
5
|
+
* spawns it — including the three quarters of directories that are not web apps at all. Measured
|
|
6
|
+
* over a day: `stack` was null for 92 of 121 profiled projects, 50 were not even git repositories,
|
|
7
|
+
* and 74% of daemon sessions never called a single tool. Median daemon lifetime 28 minutes; total
|
|
8
|
+
* uptime across those sessions 10,816 minutes against 4.6 minutes of actual work — a duty cycle of
|
|
9
|
+
* **0.04%**.
|
|
10
|
+
*
|
|
11
|
+
* The existing idle-shutdown could never fire on any of it, because its predicate is
|
|
12
|
+
* `!agentConnected && …` and an attached agent keeps `agentConnected` true for the whole editor
|
|
13
|
+
* session. So a daemon sat for half an hour, in a directory with no app, doing nothing, holding
|
|
14
|
+
* memory and a port.
|
|
15
|
+
*
|
|
16
|
+
* The rule here is deliberately the most conservative one that still catches that population:
|
|
17
|
+
* a daemon is USELESS only if it has never served a tool call AND no browser has ever connected to
|
|
18
|
+
* it AND nothing is leased from the pool. All three are lifetime facts, not windows — so nothing
|
|
19
|
+
* transient trips it, and a daemon in this state provably holds no recording, baseline or session
|
|
20
|
+
* anybody could lose. When it does shut down, the MCP proxy reconnects and `ensureDaemon` spawns a
|
|
21
|
+
* fresh one on the next call, which is a path already exercised hundreds of times a day.
|
|
22
|
+
*/
|
|
23
|
+
/** Set the first time any tool is dispatched through runTool — the chokepoint every path shares. */
|
|
24
|
+
let servedToolCall = false;
|
|
25
|
+
export function noteToolCall() {
|
|
26
|
+
servedToolCall = true;
|
|
27
|
+
}
|
|
28
|
+
export function everServedToolCall() {
|
|
29
|
+
return servedToolCall;
|
|
30
|
+
}
|
|
31
|
+
/** Tests only. */
|
|
32
|
+
export function resetDaemonUsefulness() {
|
|
33
|
+
servedToolCall = false;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* True when the daemon has done nothing for anyone and has nothing to lose by exiting.
|
|
37
|
+
*
|
|
38
|
+
* Every clause is a LIFETIME fact. "No tool call in the last five minutes" would fire on a thinking
|
|
39
|
+
* pause; "no tool call ever" cannot.
|
|
40
|
+
*/
|
|
41
|
+
export function isUselessDaemon(facts) {
|
|
42
|
+
return !facts.servedToolCall && !facts.everConnected && 0 === facts.activeLeases;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The daemon's whole "is anything happening" question.
|
|
46
|
+
*
|
|
47
|
+
* Two ways to be idle. The original — nobody attached, nothing connected. And the one that was
|
|
48
|
+
* missing: an agent IS attached but this daemon has never served a tool call and no browser has ever
|
|
49
|
+
* connected to it. `agentConnected` stays true for an entire editor session, so without the second
|
|
50
|
+
* clause the shutdown watcher could never fire on the 74% of daemons that spawn in a directory with
|
|
51
|
+
* no app and sit there for half an hour.
|
|
52
|
+
*/
|
|
53
|
+
export function buildIdlePredicate(agentConnected, sessions, pool) {
|
|
54
|
+
return () => (!agentConnected() ||
|
|
55
|
+
isUselessDaemon({
|
|
56
|
+
servedToolCall: everServedToolCall(),
|
|
57
|
+
everConnected: sessions.everConnected(),
|
|
58
|
+
activeLeases: pool.activeCount(),
|
|
59
|
+
})) &&
|
|
60
|
+
0 === sessions.count() &&
|
|
61
|
+
0 === pool.activeCount();
|
|
62
|
+
}
|
|
@@ -1,4 +1,34 @@
|
|
|
1
|
+
/** Env override for the whole state directory — see ReticleEnv.STATE_DIR. */
|
|
2
|
+
export declare const STATE_DIR_ENV: "RETICLE_STATE_DIR";
|
|
3
|
+
/**
|
|
4
|
+
* Where the daemon keeps pidfiles, the discovery registry and its logs.
|
|
5
|
+
*
|
|
6
|
+
* `~/.reticle` unless overridden. The override exists because a read-only $HOME is not hypothetical
|
|
7
|
+
* — a sandboxed agent or a locked-down Windows profile cannot write there, and until now that meant
|
|
8
|
+
* the daemon could not start at all. Read per call rather than captured at import, so a test (and a
|
|
9
|
+
* wrapper that sets it late) sees the current value.
|
|
10
|
+
*/
|
|
11
|
+
export declare function reticleStateHome(): string;
|
|
1
12
|
export declare function logPath(port: number): string;
|
|
13
|
+
/**
|
|
14
|
+
* How large a daemon log may get before it is rolled over to `<name>.1`.
|
|
15
|
+
*
|
|
16
|
+
* It was unbounded. A real dev machine reached **24MB** on one port, which is not merely untidy: it
|
|
17
|
+
* is the difference between a log somebody opens and a log somebody gives up on, and it is disk that
|
|
18
|
+
* nothing ever reclaims. One previous generation is kept, because the question people bring to this
|
|
19
|
+
* file ("what happened just now?") is answered by the current one and the question they bring next
|
|
20
|
+
* ("and just before that?") is answered by the other.
|
|
21
|
+
*/
|
|
22
|
+
export declare const MAX_DAEMON_LOG_BYTES: number;
|
|
23
|
+
/**
|
|
24
|
+
* Roll the log over if it has grown past the cap. Best-effort: a daemon must still start when the
|
|
25
|
+
* log cannot be rotated, because failing to launch over housekeeping is strictly worse than a large
|
|
26
|
+
* file.
|
|
27
|
+
*/
|
|
28
|
+
export declare function rotateDaemonLog(path: string, deps: {
|
|
29
|
+
fileSize(p: string): number;
|
|
30
|
+
renameFile(from: string, to: string): void;
|
|
31
|
+
}, max?: number): boolean;
|
|
2
32
|
export declare function readPid(port: number, home?: string): number | null;
|
|
3
33
|
export declare function isAlive(pid: number): boolean;
|
|
4
34
|
export declare function writePid(port: number): void;
|
|
@@ -20,13 +50,6 @@ export declare function writeDaemonRegistry(port: number, meta: {
|
|
|
20
50
|
projectId?: string;
|
|
21
51
|
startedAt: number;
|
|
22
52
|
}): void;
|
|
23
|
-
export declare function removeDaemonRegistry(port: number): void;
|
|
24
|
-
/**
|
|
25
|
-
* Discover the port of a live daemon serving `projectId` by reading the registry entries in ~/.reticle.
|
|
26
|
-
* Returns null when none matches (caller falls back to the default port — never guesses a mismatched
|
|
27
|
-
* daemon). Stale entries (crashed daemons) are ignored via the pid liveness probe.
|
|
28
|
-
*/
|
|
29
|
-
export declare function discoverDaemonPortForProject(projectId: string | undefined): number | null;
|
|
30
53
|
export declare function isRunning(port: number): boolean;
|
|
31
54
|
/**
|
|
32
55
|
* Find the port of a live reticle daemon by scanning ~/.reticle/daemon-<port>.pid files — so `reticle open`
|
|
@@ -52,13 +75,15 @@ export interface SpawnDaemonDeps {
|
|
|
52
75
|
readonly home: string;
|
|
53
76
|
openFile(path: string, flags: string): number;
|
|
54
77
|
closeFile(fd: number): void;
|
|
78
|
+
/** Size in bytes, or 0 when the file does not exist yet. */
|
|
79
|
+
fileSize(path: string): number;
|
|
80
|
+
renameFile(from: string, to: string): void;
|
|
55
81
|
spawnChild(command: string, args: readonly string[], options: {
|
|
56
82
|
detached: boolean;
|
|
57
83
|
stdio: readonly ('ignore' | number)[];
|
|
58
84
|
}): SpawnedChild;
|
|
59
85
|
pidAlive(pid: number): boolean;
|
|
60
86
|
}
|
|
61
|
-
export declare function defaultSpawnDaemonDeps(): SpawnDaemonDeps;
|
|
62
87
|
/**
|
|
63
88
|
* Spawn the reticle daemon as a detached background process, redirecting output to the log file.
|
|
64
89
|
* Writes the PID file from the parent before returning so callers can call isRunning
|
|
@@ -1,19 +1,62 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, openSync, closeSync, readdirSync, } from 'node:fs';
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, openSync, closeSync, readdirSync, statSync, renameSync, writeSync, } from 'node:fs';
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
|
-
import { daemonRegistryFileName,
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
import { daemonRegistryFileName, ReticleEnv } from '@reticlehq/core';
|
|
6
|
+
import { log } from '../log.js';
|
|
7
|
+
import { uncleanPredecessor } from './unclean-predecessor.js';
|
|
8
|
+
/** Env override for the whole state directory — see ReticleEnv.STATE_DIR. */
|
|
9
|
+
export const STATE_DIR_ENV = ReticleEnv.STATE_DIR;
|
|
10
|
+
/**
|
|
11
|
+
* Where the daemon keeps pidfiles, the discovery registry and its logs.
|
|
12
|
+
*
|
|
13
|
+
* `~/.reticle` unless overridden. The override exists because a read-only $HOME is not hypothetical
|
|
14
|
+
* — a sandboxed agent or a locked-down Windows profile cannot write there, and until now that meant
|
|
15
|
+
* the daemon could not start at all. Read per call rather than captured at import, so a test (and a
|
|
16
|
+
* wrapper that sets it late) sees the current value.
|
|
17
|
+
*/
|
|
18
|
+
export function reticleStateHome() {
|
|
19
|
+
const override = process.env[STATE_DIR_ENV];
|
|
20
|
+
if (override !== undefined && override.length > 0)
|
|
21
|
+
return override;
|
|
22
|
+
return join(homedir(), '.reticle');
|
|
23
|
+
}
|
|
24
|
+
function pidPath(port, home = reticleStateHome()) {
|
|
8
25
|
return join(home, `daemon-${port}.pid`);
|
|
9
26
|
}
|
|
10
27
|
function registryPath(port) {
|
|
11
|
-
return join(
|
|
28
|
+
return join(reticleStateHome(), daemonRegistryFileName(port));
|
|
12
29
|
}
|
|
13
30
|
export function logPath(port) {
|
|
14
|
-
return join(
|
|
31
|
+
return join(reticleStateHome(), `daemon-${port}.log`);
|
|
15
32
|
}
|
|
16
|
-
|
|
33
|
+
/**
|
|
34
|
+
* How large a daemon log may get before it is rolled over to `<name>.1`.
|
|
35
|
+
*
|
|
36
|
+
* It was unbounded. A real dev machine reached **24MB** on one port, which is not merely untidy: it
|
|
37
|
+
* is the difference between a log somebody opens and a log somebody gives up on, and it is disk that
|
|
38
|
+
* nothing ever reclaims. One previous generation is kept, because the question people bring to this
|
|
39
|
+
* file ("what happened just now?") is answered by the current one and the question they bring next
|
|
40
|
+
* ("and just before that?") is answered by the other.
|
|
41
|
+
*/
|
|
42
|
+
export const MAX_DAEMON_LOG_BYTES = 8 * 1024 * 1024;
|
|
43
|
+
/**
|
|
44
|
+
* Roll the log over if it has grown past the cap. Best-effort: a daemon must still start when the
|
|
45
|
+
* log cannot be rotated, because failing to launch over housekeeping is strictly worse than a large
|
|
46
|
+
* file.
|
|
47
|
+
*/
|
|
48
|
+
export function rotateDaemonLog(path, deps, max = MAX_DAEMON_LOG_BYTES) {
|
|
49
|
+
try {
|
|
50
|
+
if (max >= deps.fileSize(path))
|
|
51
|
+
return false;
|
|
52
|
+
deps.renameFile(path, `${path}.1`);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function readPid(port, home = reticleStateHome()) {
|
|
17
60
|
const path = pidPath(port, home);
|
|
18
61
|
if (!existsSync(path))
|
|
19
62
|
return null;
|
|
@@ -30,7 +73,7 @@ export function isAlive(pid) {
|
|
|
30
73
|
}
|
|
31
74
|
}
|
|
32
75
|
export function writePid(port) {
|
|
33
|
-
mkdirSync(
|
|
76
|
+
mkdirSync(reticleStateHome(), { recursive: true });
|
|
34
77
|
writeFileSync(pidPath(port), String(process.pid), 'utf8');
|
|
35
78
|
}
|
|
36
79
|
/**
|
|
@@ -39,7 +82,7 @@ export function writePid(port) {
|
|
|
39
82
|
* the orphan-race guard: a losing childB (EADDRINUSE) must not delete the winning childA's live pidfile.
|
|
40
83
|
*/
|
|
41
84
|
export function shouldRemovePid(owner, expectedPid, alive) {
|
|
42
|
-
return
|
|
85
|
+
return null === owner || owner === expectedPid || !alive;
|
|
43
86
|
}
|
|
44
87
|
export function removePid(port, expectedPid = process.pid) {
|
|
45
88
|
const path = pidPath(port);
|
|
@@ -66,14 +109,14 @@ export function writeDaemonRegistry(port, meta) {
|
|
|
66
109
|
...(meta.projectId !== undefined ? { projectId: meta.projectId } : {}),
|
|
67
110
|
};
|
|
68
111
|
try {
|
|
69
|
-
mkdirSync(
|
|
112
|
+
mkdirSync(reticleStateHome(), { recursive: true });
|
|
70
113
|
writeFileSync(registryPath(port), JSON.stringify(entry), 'utf8');
|
|
71
114
|
}
|
|
72
115
|
catch {
|
|
73
116
|
// discovery is a convenience — never block startup on it
|
|
74
117
|
}
|
|
75
118
|
}
|
|
76
|
-
|
|
119
|
+
function removeDaemonRegistry(port) {
|
|
77
120
|
const path = registryPath(port);
|
|
78
121
|
try {
|
|
79
122
|
if (existsSync(path))
|
|
@@ -83,34 +126,6 @@ export function removeDaemonRegistry(port) {
|
|
|
83
126
|
// racing another cleaner — already gone
|
|
84
127
|
}
|
|
85
128
|
}
|
|
86
|
-
/**
|
|
87
|
-
* Discover the port of a live daemon serving `projectId` by reading the registry entries in ~/.reticle.
|
|
88
|
-
* Returns null when none matches (caller falls back to the default port — never guesses a mismatched
|
|
89
|
-
* daemon). Stale entries (crashed daemons) are ignored via the pid liveness probe.
|
|
90
|
-
*/
|
|
91
|
-
export function discoverDaemonPortForProject(projectId) {
|
|
92
|
-
const entries = [];
|
|
93
|
-
let files;
|
|
94
|
-
try {
|
|
95
|
-
files = readdirSync(RETICLE_HOME);
|
|
96
|
-
}
|
|
97
|
-
catch {
|
|
98
|
-
return null; // no ~/.reticle yet
|
|
99
|
-
}
|
|
100
|
-
for (const file of files) {
|
|
101
|
-
if (daemonRegistryPort(file) === null)
|
|
102
|
-
continue;
|
|
103
|
-
try {
|
|
104
|
-
const parsed = DaemonRegistryEntrySchema.safeParse(JSON.parse(readFileSync(join(RETICLE_HOME, file), 'utf8')));
|
|
105
|
-
if (parsed.success)
|
|
106
|
-
entries.push(parsed.data);
|
|
107
|
-
}
|
|
108
|
-
catch {
|
|
109
|
-
// unreadable/corrupt entry — skip it
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
return pickDaemonPort(entries, projectId, isAlive);
|
|
113
|
-
}
|
|
114
129
|
export function isRunning(port) {
|
|
115
130
|
const pid = readPid(port);
|
|
116
131
|
return pid !== null && isAlive(pid);
|
|
@@ -124,12 +139,12 @@ export function discoverDaemonPort() {
|
|
|
124
139
|
reclaimStaleDaemons(); // sweep crashed daemons' stale pidfiles before scanning for live ones
|
|
125
140
|
let found = null;
|
|
126
141
|
try {
|
|
127
|
-
for (const file of readdirSync(
|
|
142
|
+
for (const file of readdirSync(reticleStateHome())) {
|
|
128
143
|
const m = /^daemon-(\d+)\.pid$/.exec(file);
|
|
129
|
-
if (
|
|
144
|
+
if (null === m)
|
|
130
145
|
continue;
|
|
131
146
|
const port = Number(m[1]);
|
|
132
|
-
if (isRunning(port) && (
|
|
147
|
+
if (isRunning(port) && (null === found || port < found))
|
|
133
148
|
found = port;
|
|
134
149
|
}
|
|
135
150
|
}
|
|
@@ -144,7 +159,7 @@ export function discoverDaemonPort() {
|
|
|
144
159
|
* Returns the ports reclaimed. `home` and `pidAlive` are injectable for testing (default to the real
|
|
145
160
|
* ~/.reticle and the process.kill(pid,0) liveness probe).
|
|
146
161
|
*/
|
|
147
|
-
export function reclaimStaleDaemons(home =
|
|
162
|
+
export function reclaimStaleDaemons(home = reticleStateHome(), pidAlive = isAlive) {
|
|
148
163
|
const reclaimed = [];
|
|
149
164
|
let files;
|
|
150
165
|
try {
|
|
@@ -155,7 +170,7 @@ export function reclaimStaleDaemons(home = RETICLE_HOME, pidAlive = isAlive) {
|
|
|
155
170
|
}
|
|
156
171
|
for (const file of files) {
|
|
157
172
|
const match = /^daemon-(\d+)\.pid$/.exec(file);
|
|
158
|
-
if (
|
|
173
|
+
if (null === match)
|
|
159
174
|
continue;
|
|
160
175
|
const path = join(home, file);
|
|
161
176
|
let pid = null;
|
|
@@ -167,7 +182,7 @@ export function reclaimStaleDaemons(home = RETICLE_HOME, pidAlive = isAlive) {
|
|
|
167
182
|
catch {
|
|
168
183
|
pid = null; // unreadable pidfile counts as stale
|
|
169
184
|
}
|
|
170
|
-
if (
|
|
185
|
+
if (null === pid || !pidAlive(pid)) {
|
|
171
186
|
try {
|
|
172
187
|
unlinkSync(path);
|
|
173
188
|
removeDaemonRegistry(Number(match[1])); // drop the sidecar discovery entry too
|
|
@@ -180,11 +195,13 @@ export function reclaimStaleDaemons(home = RETICLE_HOME, pidAlive = isAlive) {
|
|
|
180
195
|
}
|
|
181
196
|
return reclaimed;
|
|
182
197
|
}
|
|
183
|
-
|
|
198
|
+
function defaultSpawnDaemonDeps() {
|
|
184
199
|
return {
|
|
185
|
-
home:
|
|
200
|
+
home: reticleStateHome(),
|
|
186
201
|
openFile: openSync,
|
|
187
202
|
closeFile: closeSync,
|
|
203
|
+
fileSize: (path) => (existsSync(path) ? statSync(path).size : 0),
|
|
204
|
+
renameFile: renameSync,
|
|
188
205
|
spawnChild: (command, args, options) => spawn(command, [...args], { detached: options.detached, stdio: [...options.stdio] }),
|
|
189
206
|
pidAlive: isAlive,
|
|
190
207
|
};
|
|
@@ -198,12 +215,27 @@ export function defaultSpawnDaemonDeps() {
|
|
|
198
215
|
* pattern as reclaimStaleDaemons(home, pidAlive).
|
|
199
216
|
*/
|
|
200
217
|
export function spawnDaemon(nodeExec, scriptPath, args, port, deps = defaultSpawnDaemonDeps()) {
|
|
201
|
-
|
|
218
|
+
try {
|
|
219
|
+
mkdirSync(deps.home, { recursive: true });
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
// The log-file open below has always been guarded; this was not, so the ONE failure a sandboxed
|
|
223
|
+
// agent actually hits — a read-only $HOME — escaped as a raw EACCES naming nothing. Refuse
|
|
224
|
+
// loudly, and name the way out.
|
|
225
|
+
log('reticle_daemon_state_unwritable', {
|
|
226
|
+
home: deps.home,
|
|
227
|
+
error: err instanceof Error ? err.message : String(err),
|
|
228
|
+
fix: `Reticle could not create its state directory. Set ${STATE_DIR_ENV} to a writable path and retry.`,
|
|
229
|
+
});
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
202
232
|
const pidFilePath = join(deps.home, `daemon-${port}.pid`);
|
|
203
233
|
const logFilePath = join(deps.home, `daemon-${port}.log`);
|
|
204
234
|
// O_EXCL spawn-lock: only the FIRST racer to create the pidfile spawns. A concurrent second gets
|
|
205
235
|
// EEXIST — if a LIVE daemon owns the port it skips (no duplicate detached daemon, no clobbered pid);
|
|
206
236
|
// a stale pidfile from a crashed daemon is reclaimed. Returns false when it did not spawn.
|
|
237
|
+
/** Set when this start reclaimed a pidfile whose owner is gone — see unclean-predecessor.ts. */
|
|
238
|
+
let orphanPid = null;
|
|
207
239
|
let lockFd;
|
|
208
240
|
try {
|
|
209
241
|
lockFd = deps.openFile(pidFilePath, 'wx');
|
|
@@ -212,6 +244,10 @@ export function spawnDaemon(nodeExec, scriptPath, args, port, deps = defaultSpaw
|
|
|
212
244
|
const existing = readPid(port, deps.home);
|
|
213
245
|
if (existing !== null && deps.pidAlive(existing))
|
|
214
246
|
return false;
|
|
247
|
+
// This branch IS the crash detection — its own comment has always said so ("a stale pidfile from
|
|
248
|
+
// a crashed daemon is reclaimed"), it just never said it out loud. Captured before the unlink,
|
|
249
|
+
// because after it there is no trace left that the previous daemon died at all.
|
|
250
|
+
orphanPid = uncleanPredecessor(existing, false, process.pid);
|
|
215
251
|
try {
|
|
216
252
|
unlinkSync(pidFilePath);
|
|
217
253
|
lockFd = deps.openFile(pidFilePath, 'wx');
|
|
@@ -220,6 +256,9 @@ export function spawnDaemon(nodeExec, scriptPath, args, port, deps = defaultSpaw
|
|
|
220
256
|
return false; // lost a concurrent reclaim race
|
|
221
257
|
}
|
|
222
258
|
}
|
|
259
|
+
// Roll the log BEFORE opening the append handle, or the daemon writes into the renamed file
|
|
260
|
+
// through an fd that no longer matches any name anyone can find.
|
|
261
|
+
rotateDaemonLog(logFilePath, deps);
|
|
223
262
|
let logFd;
|
|
224
263
|
try {
|
|
225
264
|
logFd = deps.openFile(logFilePath, 'a');
|
|
@@ -235,6 +274,24 @@ export function spawnDaemon(nodeExec, scriptPath, args, port, deps = defaultSpaw
|
|
|
235
274
|
}
|
|
236
275
|
return false;
|
|
237
276
|
}
|
|
277
|
+
// Into the daemon log itself, not the parent's stdout: `doctor` points the reader at this file, and
|
|
278
|
+
// a death recorded anywhere else is a death they will not find. Written through the fd the child is
|
|
279
|
+
// about to inherit, so it lands immediately before that child's first line — exactly where a reader
|
|
280
|
+
// comparing two `mcp_daemon_started` entries is already looking.
|
|
281
|
+
if (null !== orphanPid) {
|
|
282
|
+
try {
|
|
283
|
+
writeSync(logFd, `${JSON.stringify({
|
|
284
|
+
// Same convention as log(): the clock is read at the I/O boundary, not injected.
|
|
285
|
+
t: new Date().toISOString(),
|
|
286
|
+
event: 'reticle_daemon_previous_died_unclean',
|
|
287
|
+
port,
|
|
288
|
+
pid: orphanPid,
|
|
289
|
+
})}\n`);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// A log we cannot write is not a reason to refuse to start the daemon.
|
|
293
|
+
}
|
|
294
|
+
}
|
|
238
295
|
let child;
|
|
239
296
|
try {
|
|
240
297
|
child = deps.spawnChild(nodeExec, [scriptPath, ...args], {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A regular beat, so SILENCE in the daemon log becomes evidence.
|
|
3
|
+
*
|
|
4
|
+
* `installExitTrace` hooks `'exit'`, which a SIGKILL never fires. So a daemon that is killed —
|
|
5
|
+
* crashed, OOM-ed, swept by another project, `kill -9`'d by the recipe in every troubleshooting
|
|
6
|
+
* thread — leaves no trace at all, and the last line before its port goes dark is whatever it
|
|
7
|
+
* happened to be doing. Worse, a daemon that DID exit logs `code: 0`, so a tidy shutdown and a
|
|
8
|
+
* violent death are the same shape to anyone reading the file.
|
|
9
|
+
*
|
|
10
|
+
* Measured cost of that: a correct SvelteKit install was written up as an install failure, because
|
|
11
|
+
* the daemon had died 21 seconds earlier and nothing in the log said so. The fixture was named in the
|
|
12
|
+
* report; the daemon was not.
|
|
13
|
+
*
|
|
14
|
+
* Two halves, and the second is what makes the first worth having:
|
|
15
|
+
* - DaemonHeartbeat emits `reticle_daemon_alive` on a fixed cadence, unconditionally.
|
|
16
|
+
* - classifyDaemonLife reads a log back and says which way the daemon ended.
|
|
17
|
+
*
|
|
18
|
+
* A heartbeat nobody interprets is just log volume.
|
|
19
|
+
*/
|
|
20
|
+
/** The event name a beat carries. One `grep` separates liveness from everything else in the file. */
|
|
21
|
+
export declare const DAEMON_HEARTBEAT_EVENT = "reticle_daemon_alive";
|
|
22
|
+
/** Written by installExitTrace when the process exits through Node. */
|
|
23
|
+
export declare const DAEMON_EXIT_EVENT = "reticle_daemon_exiting";
|
|
24
|
+
/** Written by installExitTrace when a signal arrives, which is a CAUSE the exit line does not carry. */
|
|
25
|
+
export declare const DAEMON_SIGNAL_EVENT = "reticle_daemon_signalled";
|
|
26
|
+
/**
|
|
27
|
+
* How often a live daemon says so.
|
|
28
|
+
*
|
|
29
|
+
* The median daemon lives 28 minutes, so at 30s that is ~56 lines — readable, and small against a log
|
|
30
|
+
* that already rotates. Shorter would make the gap sharper and the file noisier; the gap only needs
|
|
31
|
+
* to be legible against a connect window, which is measured in seconds.
|
|
32
|
+
*/
|
|
33
|
+
export declare const DAEMON_HEARTBEAT_MS = 30000;
|
|
34
|
+
/**
|
|
35
|
+
* The cadence, from the environment. Same shape as `resolveIdleCheckMs`: a bad value falls back
|
|
36
|
+
* rather than throwing, and 0 or negative is not a legal cadence — it would spin.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveHeartbeatMs(raw: string | undefined): number;
|
|
39
|
+
interface HeartbeatFacts {
|
|
40
|
+
/** Browser sessions connected right now. */
|
|
41
|
+
sessions: number;
|
|
42
|
+
/**
|
|
43
|
+
* Has this daemon EVER served a tool call? A lifetime fact, not a window count.
|
|
44
|
+
*
|
|
45
|
+
* It is the difference between a death that cost somebody work and one that cost nothing — the
|
|
46
|
+
* same distinction `isUselessDaemon` already draws. A windowed count would reset under the reader
|
|
47
|
+
* and make a long-lived daemon look freshly idle.
|
|
48
|
+
*/
|
|
49
|
+
served: boolean;
|
|
50
|
+
}
|
|
51
|
+
interface DaemonHeartbeatOptions {
|
|
52
|
+
log: (event: string, fields: Record<string, unknown>) => void;
|
|
53
|
+
/** Read at each beat, never cached — the point is to describe the daemon NOW. */
|
|
54
|
+
facts: () => HeartbeatFacts;
|
|
55
|
+
intervalMs?: number;
|
|
56
|
+
/** Injected, per the repo rule: never call Date.now() inside logic. */
|
|
57
|
+
clock?: () => number;
|
|
58
|
+
}
|
|
59
|
+
export declare class DaemonHeartbeat {
|
|
60
|
+
#private;
|
|
61
|
+
constructor(opts: DaemonHeartbeatOptions);
|
|
62
|
+
/**
|
|
63
|
+
* One beat. Exposed so a test can drive it with an injected clock rather than waiting.
|
|
64
|
+
*
|
|
65
|
+
* Unconditional on purpose. Skipping the beat when nothing happened is the obvious optimisation and
|
|
66
|
+
* it destroys the signal: silence would then mean "idle" OR "dead", which is precisely the
|
|
67
|
+
* ambiguity this exists to remove.
|
|
68
|
+
*/
|
|
69
|
+
beat(): void;
|
|
70
|
+
start(): void;
|
|
71
|
+
stop(): void;
|
|
72
|
+
}
|
|
73
|
+
/** How a daemon's life ended, as far as its own log can say. */
|
|
74
|
+
export declare const DaemonEnd: {
|
|
75
|
+
/** Still beating within tolerance. */
|
|
76
|
+
readonly ALIVE: "alive";
|
|
77
|
+
/** Logged its own exit, with no signal before it. */
|
|
78
|
+
readonly CLEAN: "clean";
|
|
79
|
+
/** A signal arrived — the cause an exit line with `code: 0` does not carry. */
|
|
80
|
+
readonly SIGNALLED: "signalled";
|
|
81
|
+
/** The beat simply stopped. No exit, no signal: killed, crashed, or swept. */
|
|
82
|
+
readonly DIED_SILENTLY: "died_silently";
|
|
83
|
+
/** No beat was ever emitted, so this log cannot answer the question. */
|
|
84
|
+
readonly UNKNOWN: "unknown";
|
|
85
|
+
};
|
|
86
|
+
export type DaemonEnd = (typeof DaemonEnd)[keyof typeof DaemonEnd];
|
|
87
|
+
/**
|
|
88
|
+
* One parsed line of a daemon log. The index signature is not laziness: these lines carry whatever
|
|
89
|
+
* their event needed (`code`, `port`, `pid`, `reason`, …), and a reader that refused unknown keys
|
|
90
|
+
* would reject the very file it exists to read.
|
|
91
|
+
*/
|
|
92
|
+
interface LogLine {
|
|
93
|
+
t: number;
|
|
94
|
+
event: string;
|
|
95
|
+
signal?: unknown;
|
|
96
|
+
everyMs?: unknown;
|
|
97
|
+
[field: string]: unknown;
|
|
98
|
+
}
|
|
99
|
+
export interface DaemonLife {
|
|
100
|
+
end: DaemonEnd;
|
|
101
|
+
/** When the last beat landed, or undefined if there was never one. */
|
|
102
|
+
lastBeatMs?: number;
|
|
103
|
+
/** How long the log has been silent, when that is the finding. */
|
|
104
|
+
silentForMs?: number;
|
|
105
|
+
signal?: string;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Read a daemon log back and say how it ended.
|
|
109
|
+
*
|
|
110
|
+
* UNKNOWN rather than ALIVE when there is no heartbeat at all — every log written before this
|
|
111
|
+
* existed, and any file the reader was pointed at by mistake. Inferring health from the absence of a
|
|
112
|
+
* signal we never sent is the same confident-wrong this module replaces.
|
|
113
|
+
*/
|
|
114
|
+
export declare function classifyDaemonLife(lines: readonly LogLine[], nowMs: number): DaemonLife;
|
|
115
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A regular beat, so SILENCE in the daemon log becomes evidence.
|
|
3
|
+
*
|
|
4
|
+
* `installExitTrace` hooks `'exit'`, which a SIGKILL never fires. So a daemon that is killed —
|
|
5
|
+
* crashed, OOM-ed, swept by another project, `kill -9`'d by the recipe in every troubleshooting
|
|
6
|
+
* thread — leaves no trace at all, and the last line before its port goes dark is whatever it
|
|
7
|
+
* happened to be doing. Worse, a daemon that DID exit logs `code: 0`, so a tidy shutdown and a
|
|
8
|
+
* violent death are the same shape to anyone reading the file.
|
|
9
|
+
*
|
|
10
|
+
* Measured cost of that: a correct SvelteKit install was written up as an install failure, because
|
|
11
|
+
* the daemon had died 21 seconds earlier and nothing in the log said so. The fixture was named in the
|
|
12
|
+
* report; the daemon was not.
|
|
13
|
+
*
|
|
14
|
+
* Two halves, and the second is what makes the first worth having:
|
|
15
|
+
* - DaemonHeartbeat emits `reticle_daemon_alive` on a fixed cadence, unconditionally.
|
|
16
|
+
* - classifyDaemonLife reads a log back and says which way the daemon ended.
|
|
17
|
+
*
|
|
18
|
+
* A heartbeat nobody interprets is just log volume.
|
|
19
|
+
*/
|
|
20
|
+
/** The event name a beat carries. One `grep` separates liveness from everything else in the file. */
|
|
21
|
+
export const DAEMON_HEARTBEAT_EVENT = 'reticle_daemon_alive';
|
|
22
|
+
/** Written by installExitTrace when the process exits through Node. */
|
|
23
|
+
export const DAEMON_EXIT_EVENT = 'reticle_daemon_exiting';
|
|
24
|
+
/** Written by installExitTrace when a signal arrives, which is a CAUSE the exit line does not carry. */
|
|
25
|
+
export const DAEMON_SIGNAL_EVENT = 'reticle_daemon_signalled';
|
|
26
|
+
/**
|
|
27
|
+
* How often a live daemon says so.
|
|
28
|
+
*
|
|
29
|
+
* The median daemon lives 28 minutes, so at 30s that is ~56 lines — readable, and small against a log
|
|
30
|
+
* that already rotates. Shorter would make the gap sharper and the file noisier; the gap only needs
|
|
31
|
+
* to be legible against a connect window, which is measured in seconds.
|
|
32
|
+
*/
|
|
33
|
+
export const DAEMON_HEARTBEAT_MS = 30_000;
|
|
34
|
+
/**
|
|
35
|
+
* How many beats may be missed before a reader calls it dead.
|
|
36
|
+
*
|
|
37
|
+
* Timers slip under load — the battery boots three servers and a browser — and a reader that cries
|
|
38
|
+
* death on one late beat is a reader people learn to ignore. Two intervals plus a margin.
|
|
39
|
+
*/
|
|
40
|
+
const MISSED_BEATS_TOLERATED = 2;
|
|
41
|
+
/**
|
|
42
|
+
* The cadence, from the environment. Same shape as `resolveIdleCheckMs`: a bad value falls back
|
|
43
|
+
* rather than throwing, and 0 or negative is not a legal cadence — it would spin.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveHeartbeatMs(raw) {
|
|
46
|
+
if (raw === undefined || '' === raw.trim())
|
|
47
|
+
return DAEMON_HEARTBEAT_MS;
|
|
48
|
+
const n = Number(raw);
|
|
49
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
50
|
+
return DAEMON_HEARTBEAT_MS;
|
|
51
|
+
return Math.floor(n);
|
|
52
|
+
}
|
|
53
|
+
export class DaemonHeartbeat {
|
|
54
|
+
#log;
|
|
55
|
+
#facts;
|
|
56
|
+
#intervalMs;
|
|
57
|
+
#clock;
|
|
58
|
+
#startedAt;
|
|
59
|
+
#timer;
|
|
60
|
+
constructor(opts) {
|
|
61
|
+
this.#log = opts.log;
|
|
62
|
+
this.#facts = opts.facts;
|
|
63
|
+
this.#intervalMs = opts.intervalMs ?? DAEMON_HEARTBEAT_MS;
|
|
64
|
+
this.#clock = opts.clock ?? (() => Date.now());
|
|
65
|
+
this.#startedAt = this.#clock();
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* One beat. Exposed so a test can drive it with an injected clock rather than waiting.
|
|
69
|
+
*
|
|
70
|
+
* Unconditional on purpose. Skipping the beat when nothing happened is the obvious optimisation and
|
|
71
|
+
* it destroys the signal: silence would then mean "idle" OR "dead", which is precisely the
|
|
72
|
+
* ambiguity this exists to remove.
|
|
73
|
+
*/
|
|
74
|
+
beat() {
|
|
75
|
+
const facts = this.#facts();
|
|
76
|
+
this.#log(DAEMON_HEARTBEAT_EVENT, {
|
|
77
|
+
uptimeMs: this.#clock() - this.#startedAt,
|
|
78
|
+
// The cadence travels WITH the beat so a reader never hard-codes it. Hard-coding is how the
|
|
79
|
+
// reader and the emitter drift apart the first time the interval is tuned.
|
|
80
|
+
everyMs: this.#intervalMs,
|
|
81
|
+
sessions: facts.sessions,
|
|
82
|
+
served: facts.served,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
start() {
|
|
86
|
+
if (this.#timer !== undefined)
|
|
87
|
+
return;
|
|
88
|
+
this.#timer = setInterval(() => this.beat(), this.#intervalMs);
|
|
89
|
+
// Never the reason a process stays alive — this is a diagnostic, not a workload.
|
|
90
|
+
this.#timer.unref();
|
|
91
|
+
}
|
|
92
|
+
stop() {
|
|
93
|
+
if (this.#timer === undefined)
|
|
94
|
+
return;
|
|
95
|
+
clearInterval(this.#timer);
|
|
96
|
+
this.#timer = undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** How a daemon's life ended, as far as its own log can say. */
|
|
100
|
+
export const DaemonEnd = {
|
|
101
|
+
/** Still beating within tolerance. */
|
|
102
|
+
ALIVE: 'alive',
|
|
103
|
+
/** Logged its own exit, with no signal before it. */
|
|
104
|
+
CLEAN: 'clean',
|
|
105
|
+
/** A signal arrived — the cause an exit line with `code: 0` does not carry. */
|
|
106
|
+
SIGNALLED: 'signalled',
|
|
107
|
+
/** The beat simply stopped. No exit, no signal: killed, crashed, or swept. */
|
|
108
|
+
DIED_SILENTLY: 'died_silently',
|
|
109
|
+
/** No beat was ever emitted, so this log cannot answer the question. */
|
|
110
|
+
UNKNOWN: 'unknown',
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Read a daemon log back and say how it ended.
|
|
114
|
+
*
|
|
115
|
+
* UNKNOWN rather than ALIVE when there is no heartbeat at all — every log written before this
|
|
116
|
+
* existed, and any file the reader was pointed at by mistake. Inferring health from the absence of a
|
|
117
|
+
* signal we never sent is the same confident-wrong this module replaces.
|
|
118
|
+
*/
|
|
119
|
+
export function classifyDaemonLife(lines, nowMs) {
|
|
120
|
+
const beats = lines.filter((l) => l.event === DAEMON_HEARTBEAT_EVENT);
|
|
121
|
+
const exit = lines.findLast((l) => l.event === DAEMON_EXIT_EVENT);
|
|
122
|
+
const signal = lines.findLast((l) => l.event === DAEMON_SIGNAL_EVENT);
|
|
123
|
+
if (exit !== undefined) {
|
|
124
|
+
// A signal is the CAUSE; the exit line only says it happened. Reporting `clean` for a SIGTERMed
|
|
125
|
+
// daemon is how "shut down tidily" and "the bridge every app needs is gone" became one sentence.
|
|
126
|
+
return signal === undefined
|
|
127
|
+
? { end: DaemonEnd.CLEAN }
|
|
128
|
+
: {
|
|
129
|
+
end: DaemonEnd.SIGNALLED,
|
|
130
|
+
...('string' === typeof signal.signal ? { signal: signal.signal } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const last = beats.at(-1);
|
|
134
|
+
if (last === undefined)
|
|
135
|
+
return { end: DaemonEnd.UNKNOWN };
|
|
136
|
+
const everyMs = 'number' === typeof last.everyMs ? last.everyMs : DAEMON_HEARTBEAT_MS;
|
|
137
|
+
const silentForMs = nowMs - last.t;
|
|
138
|
+
return silentForMs > everyMs * MISSED_BEATS_TOLERATED
|
|
139
|
+
? { end: DaemonEnd.DIED_SILENTLY, lastBeatMs: last.t, silentForMs }
|
|
140
|
+
: { end: DaemonEnd.ALIVE, lastBeatMs: last.t };
|
|
141
|
+
}
|