# Architecture This document explains the shape of the plugin: what processes exist, which of them the plugin owns, and why the exit path looks more elaborate than "call `daemon stop`". Everything here is derived from the host's actual behaviour, and several of the rules exist because the obvious version failed on a real machine. ## Process topology ``` DSH Desktop (Electron main) └── Cordis tree ← the plugin lives here (lib/index.js) ├── /hindsight-daemon/api/* ← routes for the settings page └── child: hindsight-embed daemon start (detached CLI shim) └── hindsight daemon (pythonw) listening on 127.0.0.1:9077 └── pg0 → embedded PostgreSQL on :5432 └── data: ~/.pg0/instances/hindsight-embed-/data ``` The plugin owns **three** things and nothing else: the HTTP routes, the CLI shim, and the decision of when to tear the rest down. It does not proxy memory queries and does not care what the agent stores — that path is entirely inside the Hindsight plugin. ## Why the plugin, rather than a scheduled task The upstream `@vectorize-io/hindsight-coding-agents` plugin already supports a `daemon` mode, but it cannot start the daemon itself: 1. **It only reads the DSH process environment.** `detectLlm()` looks at `process.env` (`HINDSIGHT_API_LLM_PROVIDER`, `OPENAI_API_KEY`, …) and does not read `~/.hindsight/profiles/.env`, where the configuration actually lives. When detection fails it gives up and logs `daemon mode needs an LLM for fact extraction`. 2. **It waits 12 seconds, once per process.** `seedIfCold` uses `DAEMON_WAIT_SESSION_START_MS = 12e3`, and a cold daemon start takes ~30 seconds (Python alone spends 12–13 s in `import torch` + `import sentence_transformers`). Starting the daemon from inside DSH's own Cordis tree makes the lifecycle semantic: **DSH is up → the daemon is up; DSH exits → everything is gone.** No cron entry, no scheduled task, no supervisor process polling for a parent that no longer exists. ## The three exit channels Different hosts give different guarantees about shutdown, so there are three entry points into one idempotent cleanup. | # | Channel | When it fires | Where it matters | |---|---------|---------------|------------------| | ① | Cordis disposer (the function `apply()` returns) | Host disposes the tree before quitting | Windows desktop: `main.js` intercepts quit, disposes the tree, allows 5 s | | ② | `process.on('exit')` | Normal process end, or a signal when something like `signal-exit` is loaded | Linux `dsh` CLI: the real path in practice | | ③ | `SIGTERM` / `SIGHUP` / `SIGINT` handlers | Only when nothing else listens for that signal | Fallback when the host has no signal handling at all | Channel ③ deliberately steps aside: it reads `process.listenerCount(signal)` and returns without doing anything if the count is greater than 1, handing the signal back to whoever else is handling it. Taking over would race the host's own asynchronous shutdown and cut it off mid-flight. After it *does* take over, it re-raises the signal with its own handler removed, so the exit status still looks like a normal signal-terminated process. All three funnel into one `cleanup(source)` guarded by `shared.closed`, so whichever arrives first wins and the others are no-ops. That matters because the disposer and the `exit` hook both fire on an ordinary desktop quit. ## What cleanup must do, and in which order ```text 1. kill the detached `hindsight-embed` CLI shim 2. find the daemon PID by listening port, then kill it 3. pg_ctl stop -m fast -D (~160 ms, graceful) ``` Each step exists because of an observed failure: - **The CLI shim is detached and will resurrect the daemon.** Kill only the daemon, and the shim notices and starts it again — you end up with a "stopped" daemon that is running again a second later. So the shim goes first. - **`hindsight-embed daemon stop` does not stop PostgreSQL**, and takes about 4 seconds. That is uncomfortably close to the 5 second grace window, and leaves a database behind when it does complete. Stopping the daemon directly and closing the database with `pg_ctl stop -m fast` is both faster (~160 ms) and complete. - **`-m fast` rather than `-m immediate`**: fast mode disconnects clients and flushes cleanly, so the next start does not have to replay WAL. `immediate` is what you get when you SIGKILL, and it is exactly what this path is trying to avoid. Everything is synchronous (`spawnSync`, `readFileSync`) because signal handlers cannot await. Measured cost: `apply()` returns in ~2 ms (DSH startup is not blocked), and the whole cleanup takes **500–600 ms**, well inside the 5 second window. ## Settings API `registerApi()` attaches `/hindsight-daemon/api/*` to DSH's `webServer` service. Two constraints shaped it: - **The service is polled, not injected.** Declaring `inject: ['webServer']` at the top level makes cordis resolve it *before* `apply()`, and if the context is already inactive it throws `cannot get required service ... in inactive context` — which kills DSH startup and cannot be caught inside `apply()`. Instead `apply()` polls `ctx.get('webServer')` every 500 ms for up to 60 attempts (~30 s) and degrades to "no settings page, lifecycle still works" when it never appears. That is the normal case for the headless `/cli` profile. - **Requests are gated on the `Host` header.** The routes are local-only, but a browser is a confused deputy: a page served from an attacker-controlled domain can be pointed at `127.0.0.1` (DNS rebinding), and would then be able to POST `config-save` and read back the configuration. `isTrusted()` therefore compares the request's `Host` against an allowlist — `127.0.0.1`, `localhost`, `[::1]`, `::1`, plus whatever the host declares in `webRuntime.trustedHosts` — and answers `403` otherwise. Rebinding attacks carry the attacker's own hostname, so they fail this check. ## Status and configuration flow There is exactly one `status` object (`lib/core/status.js`), never copied. The host lifecycle writes to it, the HTTP layer serialises it, and the settings page polls it every 2 seconds. Keeping one object is what prevents the page from showing "running" while the process is already dead. Configuration lives in `/hindsight-daemon/config.json` — deliberately *not* in the profile's `cordis.patch.yml`, so that uninstalling the plugin leaves the DSH profile clean. Writes are temp-file-plus-rename, and reads run every value through a type check: the file is meant to be hand-editable, and a string port or numeric profile name should degrade to the default rather than make behaviour unpredictable. `lib/config.js` uses nested keys because that reads well in JSON; the `core/` modules use flat keys because they mirror Hindsight's environment variable names. `toFlat()` is the single conversion point between the two. ## Deliberate non-goals - **No retry loop on start failure.** A failed start leaves a visible error and a log excerpt; retrying in the background turns a clear failure into a flapping service. - **No log shipping.** The page shows the tail of the plugin log and the daemon log because that is what a user needs at the moment something breaks. - **No state beyond files.** Nothing is kept in memory that is not re-derivable from the config file, the profile env file, and the on-disk database directory. That is what makes "restart DSH" a complete remedy. ## Debugging the exit path Set `DSH_HINDSIGHT_DEBUG=1` and the plugin appends to `/hindsight-daemon/debug.log`: ```text 退出钩子已注册 pid=58065 SIGTERM=2 SIGINT=2 exit=3 收到 SIGTERM,listeners=2 pid=58065 cleanup(exit) enabled=true stopOnExit=true busy=null ``` The `listeners` count is the one to read: `>1` means the host or a dependency already owns the signal, so channel ③ stood aside and the actual cleanup came through channel ②. `=1` means channel ③ took over. If you see no `cleanup(...)` line at all, the process was killed by `SIGKILL` and nothing in any userspace process could have run — see the manual cleanup commands in the README.