diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 219bdc05..3b9e1768 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -26,7 +26,8 @@ forwarding, or anything on a machine other than the one you ran it on. **Requires:** macOS with Codex Desktop installed and signed in, HypAware installed from the package under test, and a working `~/.codex`. -**Related:** [LLP 0141](../llp/0141-codex-desktop-rides-the-codex-adapter.decision.md). +**Related:** [LLP 0141](../llp/0141-codex-desktop-rides-the-codex-adapter.decision.md), +[LLP 0164](../llp/0164-status-names-recent-clients-from-gateway-entrypoints.decision.md). ### Steps @@ -73,6 +74,23 @@ installed from the package under test, and a working `~/.codex`. `entrypoint` value you observed into the release notes: that string is the one every "is Desktop landing?" query keys off. + Then confirm the same answer without a query, which is the check a user + who has not learned SQL will actually run: + + ```sh + hyp status + hyp status --json | grep -A 6 recent_entrypoints + ``` + + Pass condition: a `recent clients:` line naming the same `entrypoint` + string you just observed, with an age of a few minutes. This is read from + the running daemon's `status.json`, not from the cache + ([LLP 0164](../llp/0164-status-names-recent-clients-from-gateway-entrypoints.decision.md)), + so two things follow and both are expected, not failures: a daemon that + has been restarted since the conversation shows nothing here (the rows are + still in the cache - step 4 is the durable check), and the list is bounded + to what this daemon process has seen. + 5. Confirm the backfill route independently. The rollout tree is shared by Codex CLI and Codex Desktop, so the session from step 3 must also be re-importable from disk: @@ -137,7 +155,14 @@ installed from the package under test, and a working `~/.codex`. - Rows arrive but `entrypoint` is null: Codex sent no `originator` header on that route. Capture still worked; attribution did not. File that as its own issue with the observed request path, and do not paper over it by matching - on `client_name` alone. + on `client_name` alone. `hyp status`'s `recent clients:` line will also be + missing the Desktop entry, for the same one reason: it counts `entrypoint` + values and invents nothing for a row that has none. +- The query in step 4 finds Desktop rows but `hyp status` names no recent + client: the daemon that captured them has since restarted (the tracker is + in-memory and daemon-scoped by design), or the gateway wrote no status + refresh before it exited. Re-run step 3 against the current daemon before + filing anything. - Step 5 finds no rollout for the session: Desktop wrote its history somewhere other than `$CODEX_HOME/sessions`. That would invalidate [LLP 0141](../llp/0141-codex-desktop-rides-the-codex-adapter.decision.md)'s diff --git a/hypaware-core/plugins-workspace/ai-gateway/src/entrypoint_activity.js b/hypaware-core/plugins-workspace/ai-gateway/src/entrypoint_activity.js new file mode 100644 index 00000000..2b97bff1 --- /dev/null +++ b/hypaware-core/plugins-workspace/ai-gateway/src/entrypoint_activity.js @@ -0,0 +1,110 @@ +// @ts-check + +import { sanitizeLabel } from 'hypaware/core/util' + +/** + * How many distinct `entrypoint` values the tracker keeps. The set is + * naturally tiny (one per client surface on the machine: `codex-tui`, + * whatever Codex Desktop reports, `local-agent`, ...), but `entrypoint` + * is a captured string nothing on the way in constrains, so an odd or + * hostile client must not be able to grow a daemon-lifetime map without + * bound. On overflow the least recently seen entry is evicted, which is + * exactly the entry a "recent clients" readout would drop anyway. + * + * A count cap alone is not a bound: 32 entries of unbounded length is + * still unbounded, and `status.json` is rewritten on every daemon tick. + * `sanitizeLabel` supplies the other half (see `record`). + */ +const MAX_TRACKED_ENTRYPOINTS = 32 + +/** + * Track which client surfaces have produced rows through this gateway, + * and when. The daemon lifts the snapshot into `status.json` so + * `hyp status` can answer "did Codex Desktop traffic arrive recently?" + * without a cache read and without any client-specific knowledge in + * core: the tracker never interprets an `entrypoint`, it only counts and + * timestamps whatever the projector put in the column. + * + * In-memory and daemon-scoped by construction. It is an activity signal, + * not a store: the cache remains the only durable record of a row. + * + * @param {{ max?: number, now?: () => number }} [options] + * @ref LLP 0164#gateway-tracks-what-core-cannot-name [implements]: the gateway counts and timestamps entrypoints it never interprets + */ +export function createEntrypointActivity(options = {}) { + const max = options.max ?? MAX_TRACKED_ENTRYPOINTS + const now = options.now ?? (() => Date.now()) + /** @type {Map} */ + const seen = new Map() + + return { + /** + * Fold a batch of projected message rows into the activity map. Rows + * with no `entrypoint` are ignored rather than bucketed under a + * placeholder: "unknown" is not a client surface, and inventing one + * would put a name in `hyp status` that no query can reproduce. + * + * `entrypoint` and `client_name` are sanitized before they are stored, + * because this map is the source for a file on disk and for text + * printed to a terminal. The values are captured verbatim from the + * wire (`originator`) or, for Claude, copied off a transcript `.jsonl` + * line on disk - and that second route has no HTTP parser bounding its + * length or rejecting control bytes. Sanitizing at the point of record + * (rather than at render) also keeps the map key itself clean, so the + * eviction cap above cannot be diluted by values that differ only in + * bytes no reader will ever see. + * + * @param {readonly Record[]} rows + */ + record(rows) { + if (!Array.isArray(rows) || rows.length === 0) return + const at = now() + for (const row of rows) { + if (!row || typeof row !== 'object') continue + const entrypoint = sanitizeLabel(row.entrypoint) + if (entrypoint === undefined) continue + const clientName = sanitizeLabel(row.client_name) ?? null + const existing = seen.get(entrypoint) + if (existing) { + existing.lastSeenMs = at + existing.rows += 1 + if (clientName) existing.clientName = clientName + // Re-insert so Map iteration order stays least-recently-seen + // first, which is what the eviction below relies on. + seen.delete(entrypoint) + seen.set(entrypoint, existing) + continue + } + seen.set(entrypoint, { clientName, lastSeenMs: at, rows: 1 }) + while (seen.size > max) { + const oldest = seen.keys().next() + if (oldest.done) break + seen.delete(oldest.value) + } + } + }, + + /** + * The status-file view: most recently seen first, ISO timestamps, and + * snake_case keys because this lands verbatim in `status.json` + * alongside the gateway's other `details`. + * + * @returns {{ entrypoint: string, client_name: string | null, last_seen: string, rows: number }[]} + */ + snapshot() { + return Array.from(seen.entries()) + .map(([entrypoint, entry]) => ({ + entrypoint, + client_name: entry.clientName, + last_seen: new Date(entry.lastSeenMs).toISOString(), + rows: entry.rows, + })) + .sort((a, b) => (a.last_seen < b.last_seen ? 1 : a.last_seen > b.last_seen ? -1 : 0)) + }, + + /** @returns {number} */ + size() { + return seen.size + }, + } +} diff --git a/hypaware-core/plugins-workspace/ai-gateway/src/source.js b/hypaware-core/plugins-workspace/ai-gateway/src/source.js index 94751dba..e1438cca 100644 --- a/hypaware-core/plugins-workspace/ai-gateway/src/source.js +++ b/hypaware-core/plugins-workspace/ai-gateway/src/source.js @@ -10,6 +10,7 @@ import { import { compileConfig, FALLBACK_LISTEN } from './config.js' import { createControlHandler } from './control.js' import { AI_GATEWAY_SCHEMA_COLUMNS, aiGatewayTablePath, DATASET_NAME } from './dataset.js' +import { createEntrypointActivity } from './entrypoint_activity.js' import { createAiGatewayMessageProjector } from './message_projector.js' import { startProxy } from './proxy.js' import { createRecorder } from './recorder.js' @@ -36,8 +37,17 @@ export function createStartSource(state) { * @returns {Promise} */ return async function startAiGatewaySource(ctx) { - /** @type {{ rowsWritten: number, exchangeBytes: number, lastError: string | undefined, listenFallbackFrom: string | undefined }} */ - const liveState = { rowsWritten: 0, exchangeBytes: 0, lastError: undefined, listenFallbackFrom: undefined } + /** @type {{ rowsWritten: number, exchangeBytes: number, lastError: string | undefined, listenFallbackFrom: string | undefined, entrypoints: ReturnType }} */ + const liveState = { + rowsWritten: 0, + exchangeBytes: 0, + lastError: undefined, + listenFallbackFrom: undefined, + // Lives on `liveState`, not on the per-bind closure below, so a + // config reload (which tears the listener down and builds a fresh + // recorder) does not erase what this daemon has already seen. + entrypoints: createEntrypointActivity(), + } let proxy = await launchListener(ctx, state, liveState) @@ -61,6 +71,13 @@ export function createStartSource(state) { ...(liveState.listenFallbackFrom ? { listen_fallback: true, listen_fallback_from: liveState.listenFallbackFrom } : {}), + // Which client surfaces have actually produced rows through this + // gateway, and when. The daemon refreshes source details on every + // tick, so this reaches status.json steadily and `hyp status` can + // answer "did Codex Desktop traffic arrive recently?" with no + // cache read (LLP 0164). + // @ref LLP 0164#status-reads-it-from-the-status-file [implements]: last-seen entrypoints ride the gateway source's status details + recent_entrypoints: liveState.entrypoints.snapshot(), }, } if (liveState.lastError) status.lastError = liveState.lastError @@ -93,7 +110,7 @@ export function createStartSource(state) { * * @param {PluginActivationContext} ctx * @param {GatewayState} state - * @param {{ rowsWritten: number, exchangeBytes: number, lastError: string | undefined, listenFallbackFrom: string | undefined }} liveState + * @param {{ rowsWritten: number, exchangeBytes: number, lastError: string | undefined, listenFallbackFrom: string | undefined, entrypoints: ReturnType }} liveState * @returns {Promise} */ async function launchListener(ctx, state, liveState) { @@ -132,6 +149,10 @@ async function launchListener(ctx, state, liveState) { if (messageRows.length > 0) { await ctx.storage.appendRows(tablePath, [...AI_GATEWAY_SCHEMA_COLUMNS], messageRows) liveState.rowsWritten += messageRows.length + // Recorded only after the append resolves: "recent clients" in + // `hyp status` must mean rows that landed, not rows that were + // projected and then lost to a write failure. + liveState.entrypoints.record(messageRows) kernelInstruments.rowsWritten.add(messageRows.length, { [Attr.DATASET]: DATASET_NAME, [Attr.PLUGIN]: PLUGIN_NAME, diff --git a/hypaware-core/plugins-workspace/claude-desktop/src/verify.js b/hypaware-core/plugins-workspace/claude-desktop/src/verify.js index b108974a..8b398526 100644 --- a/hypaware-core/plugins-workspace/claude-desktop/src/verify.js +++ b/hypaware-core/plugins-workspace/claude-desktop/src/verify.js @@ -86,9 +86,14 @@ export async function runVerify(argv, cmdCtx, opts) { cmdCtx.stdout.write(' 2. Send it a message.\n') // 'local-agent' is what Desktop's 3p mode writes on the current build; // 'claude-desktop-3p' was observed on an earlier one (LLP 0133#attribution). + // Pointing at `hyp status` was aspirational until the gateway started + // tracking last-seen entrypoints: the command activates no plugins and + // reads no cache, so it had no way to see a row. It does now. + // @ref LLP 0164#status-reads-it-from-the-status-file [implements]: "confirm capture via hyp status" is a check a human can actually run cmdCtx.stdout.write( - " 3. Confirm capture: rows land under entrypoint 'local-agent' (older builds: 'claude-desktop-3p') " - + "in ai_gateway_messages (check via 'hyp status' or 'hyp mcp').\n", + " 3. Confirm capture: run 'hyp status' and look for entrypoint 'local-agent' " + + "(older builds: 'claude-desktop-3p') under 'recent clients'. The rows themselves " + + "are in ai_gateway_messages (query via 'hyp query' or 'hyp mcp').\n", ) if (!result.ok) { diff --git a/hypaware-core/smoke/flows/gateway_codex_capture.js b/hypaware-core/smoke/flows/gateway_codex_capture.js index a1832098..48f20503 100644 --- a/hypaware-core/smoke/flows/gateway_codex_capture.js +++ b/hypaware-core/smoke/flows/gateway_codex_capture.js @@ -171,6 +171,28 @@ export async function run({ harness, expect }) { await sleep(120) await handle.stop() await handle.done + + // ----- The gateway's last-seen entrypoints reached status.json ----- + // This is the half of `hyp status` that answers "did Desktop traffic + // arrive?" with no dataset registry and no cache read (LLP 0164). The + // originator above is synthetic, as the header block says, so what this + // proves is the plumbing: an entrypoint the projector wrote into a row it + // committed is readable from the status file after the daemon exits. + // @ref LLP 0164#status-reads-it-from-the-status-file [tests]: + const { readStatusFile, recentEntrypointsFromSources } = await import('../../../src/core/daemon/status.js') + const finalStatus = readStatusFile(path.join(harness.hypHome, 'hypaware')) + const recent = recentEntrypointsFromSources(finalStatus?.sources) + expect.that( + 'status.json: recent_entrypoints names the Desktop originator the rows carry', + recent, + (v) => Array.isArray(v) && v.some((e) => e.entrypoint === 'Codex Desktop' && e.rows > 0), + ) + expect.that( + 'status.json: the recent-entrypoint entry carries the projected client_name', + recent.find((e) => e.entrypoint === 'Codex Desktop')?.clientName, + (v) => typeof v === 'string' && v.length > 0, + ) + await obs.shutdown() await openai.close() diff --git a/llp/0086-attach-tracks-ephemeral-port.decision.md b/llp/0086-attach-tracks-ephemeral-port.decision.md index 0693198f..92a926e4 100644 --- a/llp/0086-attach-tracks-ephemeral-port.decision.md +++ b/llp/0086-attach-tracks-ephemeral-port.decision.md @@ -37,7 +37,10 @@ so "the proven value changes" is the common case, not an edge. The data to detect the drift already exists on disk: the gateway source's `status()` returns `details: { host, port, ... }`, which `startConfiguredSources` -captures into each `SourceSnapshot.details` in `status.json`; and the client +captures into each `SourceSnapshot.details` in `status.json` (and, since +[LLP 0164](./0164-status-names-recent-clients-from-gateway-entrypoints.decision.md), +the tick loop re-captures on the way past - which changes nothing here, since +`host` and `port` are fixed at bind time); and the client settings marker records the `port` it attached at (`probeClientAttachFromDescriptor` reads it back). Nothing compared them. diff --git a/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md b/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md index 58c178f4..b4b79b87 100644 --- a/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md +++ b/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md @@ -95,17 +95,20 @@ a `covered_by` attribute naming both routes, so the flag cannot be read as - The picker gains no new row. Codex Desktop is not a separate pick, because it is not a separate attach. -- `hyp status` still cannot say "Codex Desktop traffic arrived recently". - It boots with no plugins activated by design (`decideBootProfile` returns +- `hyp status` can say "Codex Desktop traffic arrived recently" as of + [LLP 0164](./0164-status-names-recent-clients-from-gateway-entrypoints.decision.md), + which resolved what this document left open. The constraint that made it a + design decision rather than a bug fix still holds: `hyp status` boots with + no plugins activated by design (`decideBootProfile` returns `{ activate: [] }` for `status`), so it has no dataset registry and no - cache read; and client-specific knowledge in core would contradict + cache read, and client-specific knowledge in core would contradict [LLP 0130](./0130-declarative-picker-descriptors.decision.md)'s - "rendering needs no plugin code" and LLP 0003's core/plugin split. Closing - that gap needs a design decision (a declarative activity-probe descriptor, - a status boot-profile change, an entrypoint section in - `hyp query overview`, or gateway-tracked last-seen entrypoints in - `status.json`), and is deliberately left open here. Until then the - supported check is the query itself, documented in + "rendering needs no plugin code" and LLP 0003's core/plugin split. Of the + four candidate shapes named here, the gateway-tracked one was chosen: the + gateway counts and timestamps the `entrypoint` values it writes, the daemon + carries them into `status.json`, and core renders them without + interpreting any of them. The list is in-memory and daemon-scoped, so the + **query remains the durable check** and is still the pass condition in [`docs/ACCEPTANCE.md`](../docs/ACCEPTANCE.md). - Proving the live Desktop route end to end needs Codex Desktop on a real machine, which no hermetic smoke can supply. `docs/ACCEPTANCE.md` carries diff --git a/llp/0164-status-names-recent-clients-from-gateway-entrypoints.decision.md b/llp/0164-status-names-recent-clients-from-gateway-entrypoints.decision.md new file mode 100644 index 00000000..2bfd3059 --- /dev/null +++ b/llp/0164-status-names-recent-clients-from-gateway-entrypoints.decision.md @@ -0,0 +1,197 @@ +# LLP 0164: `hyp status` names recent clients from gateway-tracked entrypoints + +**Type:** Decision +**Status:** Accepted +**Systems:** Sources, Gateway, Daemon, Onboarding +**Author:** Phil / Claude +**Date:** 2026-07-31 +**Related:** LLP 0003, LLP 0017, LLP 0086, LLP 0114, LLP 0130, LLP 0131, LLP 0133, LLP 0141 + +> Closes the one consequence [LLP 0141](./0141-codex-desktop-rides-the-codex-adapter.decision.md) +> left deliberately open: `hyp status` could not say "Codex Desktop traffic +> arrived recently". The gateway now counts and timestamps the `entrypoint` +> values it writes into rows, the daemon carries that into `status.json` on +> every tick, and `hyp status` renders it. No dataset registry, no cache read, +> and no client-specific string anywhere in core. + +## Context + +[LLP 0141](./0141-codex-desktop-rides-the-codex-adapter.decision.md) made +Codex Desktop coverage legible in the picker, the reference docs, and the +`unsupported_location` event, and wrote the manual `codex_desktop_capture` +procedure. It closed with one item open, and named four candidate shapes for +closing it: + +1. a declarative activity-probe key on the client descriptor, +2. changing `hyp status`'s boot profile, or giving it a cache read, +3. a fifth entrypoint section in `hyp query overview`, +4. the gateway tracking last-seen entrypoints into `status.json`. + +The constraint that rules out the obvious answers: `decideBootProfile` +(`src/core/cli/dispatch.js`) returns `{ activate: [] }` for `status`, on +purpose - a diagnostics command must not bind a gateway or OTLP listener just +to print a report. So `hyp status` has no dataset registry and cannot see a +row. Teaching core which `entrypoint` strings mean "Codex Desktop" would put +client-specific data in core, against +[LLP 0130](./0130-declarative-picker-descriptors.decision.md)'s "rendering +needs no plugin code" and LLP 0003's core/plugin split. + +The same gap had already produced a false instruction elsewhere: +`claude-desktop verify` told the user to "confirm capture via `hyp status`", +which was not achievable for exactly this reason. + +## Decision + +**Option 4.** The gateway tracks last-seen entrypoints; `hyp status` reports +them from `status.json`. + +**The gateway counts and +timestamps entrypoints it never interprets.** As each batch of projected +message rows is committed, the AI-gateway source folds their `entrypoint` and +`client_name` values into an in-memory map: last-seen timestamp and a row +count per distinct `entrypoint`. It is recorded *after* the append resolves, +so "recent clients" means rows that landed, not rows that were projected and +then lost to a write failure. + +Three properties make this the client-agnostic option rather than a disguised +version of option 2: + +- **No interpretation.** Nothing in the gateway or in core decides what a + value means. `codex-tui`, whatever Codex Desktop reports, and + `local-agent` are all just strings that arrived in a column. The vendor + owns them ([LLP 0141](./0141-codex-desktop-rides-the-codex-adapter.decision.md) + pins none of them), and the value printed is byte-identical to the one a + follow-up `ai_gateway_messages` query filters on - for every value a real + client surface produces. The one exception is deliberate: a value carrying + control, invisible, or display-reordering characters, or running past + 120 characters, is cleaned before it is displayed (see **Bounded** + below), so it is the row, not the readout, that stays authoritative for + a pathological name. +- **No placeholder.** A row with no `entrypoint` is skipped, not bucketed + under "unknown". A name in `hyp status` that no query can reproduce would + be worse than a short list. +- **Bounded, in both dimensions.** The map is capped (32) with + least-recently-seen eviction, so an odd or hostile client cannot grow + daemon-lifetime state, and the entry an overflow drops is the one a + "recent" readout would have dropped anyway. + + A count cap is only half a bound, because nothing on the way in + constrains the *value*. Two of the three routes into the column are + bounded by the HTTP parser (Codex's `originator` header and the + User-Agent product, which cannot carry C0 control bytes and cap around + 16KB), but the third is not: for Claude the live projector copies + `entrypoint` off a transcript `.jsonl` line on disk + (`assignTranscriptIdentity`), and that is an ordinary JSON string of any + length containing any byte. Since this map is the source for a file on + disk and for text printed to a terminal, values are passed through + `sanitizeLabel` at the point of record, and again in core when read + back. Without it, a transcript value could repaint the operator's + screen or forge a plausible extra `hyp status` line, and 32 unbounded + values would be rewritten into `status.json` on every tick. + + `sanitizeLabel` clamps to 120 characters (marker included) and strips + every character that either drives the terminal or occupies no width + on it: C0/DEL/C1 and the line separators, bidi formatting, and the + zero-width and default-ignorable formatting characters. The last group + is not cosmetic. Stripping at the point of record is what keeps the + map *key* clean, and a key differing only in characters that render as + nothing would mint unlimited entries an operator cannot tell apart - + filling all 32 slots with one surface and evicting every real one. What + is deliberately *not* attempted is confusables: this bounds what a + label does, not what it looks like, so two labels built from different + but similar-looking real letters stay distinct, as they must. + +This is an **activity signal, not a store**. It is in-memory and +daemon-scoped: it dies with the process and is never written anywhere but the +status snapshot. The cache remains the only durable record of a row. + +**`hyp status` reads it from +`status.json`, which the daemon now refreshes.** Boot wrote each source's +`status()` details exactly once (`startConfiguredSources`), which was enough +while every detail was fixed at bind time: host, port, and the LLP 0114 +fallback marker never change after the bind. It is not enough for a detail +that accrues. So the daemon re-reads started sources' details on every sink +tick, and once more at shutdown before the sources are torn down (a daemon +that stopped between ticks must not leave a file claiming it saw nothing). +Name, plugin, and state are left alone: liveness is the lifecycle's business, +not a status probe's, and the refresh is best-effort per source. + +Putting a plugin call on the tick path is the widest thing this decision +does, so the refresh bounds it rather than trusting it. `status()` is plugin +code and the kernel contract puts no limit on what it may do. Awaited only at +boot, a probe that never settles is at least loud: the daemon does not start. +Awaited every tick it would be silent and total, because `persist()` is +downstream of the refresh: *every* field in `status.json` would freeze, not +just that source's, while the daemon went on reporting itself healthy, and +the shutdown refresh would hang `hyp daemon stop` with it. So each probe runs +under a timeout, a source with a probe still outstanding is skipped rather +than piling up a fresh call (and a fresh unclosed span) every tick, and a +probe that throws or times out leaves the details it already had. + +A failure is reported **once per transition, not once per tick**. The old +boot-only path swallowed a throwing `status()` in silence, which was +survivable when it happened once; at tick cadence, silence would hide a +permanently broken plugin behind details that simply never change, and +logging unconditionally would trade that for a line every tick for the +daemon's life. Neither is observable in the sense LLP 0033 means. So the +daemon logs `daemon.source_status_failed` when a source's failure changes, +and `daemon.source_status_recovered` when it stops. + +Core's whole share of the feature is `recentEntrypointsFromSources`: find the +gateway source's snapshot, validate the shape, order by time, drop what is +malformed, sanitize each label, and cap the list. The sanitizing and the cap +are deliberately duplicated from the gateway rather than delegated to it: +`status.json` is a *file*, and core cannot assume the daemon that wrote it +was this version, this build, or well behaved, while everything read here is +about to be printed to a terminal. All three ways a label can be hostile are +answered at that last point before render - control and invisible bytes, +unbounded length, and unbounded count. + +`hyp status` then renders a `recent clients:` block, and +`--json` a `recent_entrypoints` array. The block appears only when the daemon +recorded something, so an install that has never captured keeps the V1 text +surface unchanged. + +This also makes `claude-desktop verify`'s step 3 true rather than +aspirational: Claude Desktop's 3p route lands under `entrypoint: +"local-agent"` ([LLP 0133](./0133-desktop-solo-sudo-plist.decision.md)#attribution), +which is a value like any other, so it appears with no Claude-specific code. + +**Unlike a bound port, it is not gated on +daemon liveness.** `resolveLiveGatewayEndpointFromStatus` checks the pid file +first, because a port in a stale status file is a claim about *now* and +becomes false the moment the daemon dies. "Last seen at T" does not: it stays +true afterwards. So the list is reported whether or not a daemon is running, +and the rendered age (`just now` / `5m ago` / `3h ago` / `2d ago`) is what +carries the staleness. The age is coarse on purpose - the question the line +answers is "was that just now, or last week?", and a precise timestamp would +invite reading the value as a query bound it is not. + +## Consequences + +- **A restart clears the list.** The tracker is in-memory, so a daemon that + restarts after a conversation reports no recent client for it even though + the rows are in the cache. This is the price of not adding a durable store + for a diagnostic, and it is documented as an expected outcome in + [`docs/ACCEPTANCE.md`](../docs/ACCEPTANCE.md)'s `codex_desktop_capture` + step 4 and its failure notes. The query remains the durable check. +- **Row counts are per daemon process, not per install.** `rows` in the + status output counts what this process committed. It is an activity + signal; `select count(*) from ai_gateway_messages` is the number. +- **Only gateway-captured surfaces appear.** Backfill writes rows through the + materializer, not through the live gateway, so importing a Desktop rollout + does not put Desktop in `recent clients`. That is the honest reading of the + line ("traffic arrived here"), but it means the two capture routes of + [LLP 0141](./0141-codex-desktop-rides-the-codex-adapter.decision.md) are + not symmetric on this surface. +- **`status.json` writes are marginally larger and slightly more frequent in + content**, since the tick now re-probes source details. The tick already + ran and already wrote the file (LLP 0017), so this adds a bounded + per-source `status()` call, not a new loop. +- **`hyp query overview` was not changed.** Option 3 would have altered that + block's calibrated window budget (`OVERVIEW_SECTIONS.length` feeds + `rowsAffordable`, LLP 0135#window); this decision leaves it untouched. +- **The live route is still only provable on real hardware.** Nothing here + changes that: `gateway_codex_capture` asserts the plumbing against a + synthetic Desktop-shaped exchange, and the real-app claim stays with the + manual `codex_desktop_capture` procedure. diff --git a/src/core/commands/status.js b/src/core/commands/status.js index fd7e862d..b31098c3 100644 --- a/src/core/commands/status.js +++ b/src/core/commands/status.js @@ -194,6 +194,17 @@ export function renderStatusJson({ report, clientNames, datasets, cacheRoot }) { client_sync: report.clientSync ? { syncing: report.clientSync.syncing, local_only: report.clientSync.localOnly } : null, + // Client surfaces the daemon's gateway actually produced rows for (LLP + // 0164). Always an array so a consumer can pin the key; empty means "no + // daemon has recorded any", which is not the same as "no traffic ever" - + // the cache is the durable record, this is only the activity signal. + // @ref LLP 0164#status-reads-it-from-the-status-file [implements]: --json carries the machine-readable last-seen list + recent_entrypoints: report.recentEntrypoints.map((e) => ({ + entrypoint: e.entrypoint, + client_name: e.clientName, + last_seen: e.lastSeen, + rows: e.rows, + })), datasets: datasets.map((d) => ({ name: d.name, plugin: d.plugin })), cache: { dir: cacheRoot, @@ -367,6 +378,24 @@ export function renderStatusText({ report, clientNames, datasets, cacheRoot, std ) } + // Which client surfaces have actually produced rows, and when (LLP 0164). + // This is the line that answers "did Codex Desktop traffic arrive?" and + // "did Claude Desktop's 3p route land?" without a query. Rendered only when + // the daemon recorded something, so an install that has never captured + // keeps the V1 text surface unchanged; the entrypoint strings are printed + // verbatim because they are the client's to choose, and they are the exact + // values a follow-up `ai_gateway_messages` query filters on. + // @ref LLP 0164#status-reads-it-from-the-status-file [implements]: hyp status names recent client surfaces and their age + if (report.recentEntrypoints.length > 0) { + stdout.write(' recent clients:\n') + for (const e of report.recentEntrypoints) { + const client = e.clientName ? ` (${e.clientName})` : '' + stdout.write( + ` - ${e.entrypoint}${client} last seen ${formatEntrypointAge(e.lastSeen)}, ${e.rows} row${e.rows === 1 ? '' : 's'}\n` + ) + } + } + stdout.write(` cache: ${cacheRoot}\n`) stdout.write( ` cache retention: ${report.retention.days} days${ @@ -462,6 +491,31 @@ export function renderStatusText({ report, clientNames, datasets, cacheRoot, std } } +/** + * Human-readable age of a `recent clients` entry. Coarse on purpose: the + * question the line answers is "was that just now, or last week?", and a + * precise timestamp would invite reading the value as a query bound it is + * not (the durable record is the cache). A future timestamp - a status file + * written under a clock that has since gone backwards - reads as `just now` + * rather than a negative age. + * + * @param {string} lastSeen ISO timestamp + * @param {number} [nowMs] + * @returns {string} + */ +export function formatEntrypointAge(lastSeen, nowMs = Date.now()) { + const thenMs = Date.parse(lastSeen) + if (Number.isNaN(thenMs)) return 'at an unreadable time' + const deltaSec = Math.floor((nowMs - thenMs) / 1000) + if (deltaSec < 60) return 'just now' + const minutes = Math.floor(deltaSec / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + /** * Per-entry layer provenance tag for `hyp status` text. Empty on a host * that never joined (no central layer → the V1 surface is unchanged); diff --git a/src/core/daemon/runtime.js b/src/core/daemon/runtime.js index 07ffbde3..2f0a3d47 100644 --- a/src/core/daemon/runtime.js +++ b/src/core/daemon/runtime.js @@ -471,6 +471,122 @@ export async function runDaemon(opts = {}) { } // ----- Tick loop ----- + /** + * How long a source's `status()` may take before the tick gives up on it. + * `status()` is plugin code and the kernel contract puts no bound on it. It + * used to be awaited only at boot, where a probe that never settles is at + * least loud: the daemon does not start. On the tick path a hang is silent + * and total - `persist()` is downstream of the refresh, so *every* field in + * `status.json` freezes, not just that source's, while the daemon goes on + * reporting itself healthy; and the shutdown refresh would hang + * `hyp daemon stop` with it. Well under the tick interval floor so a slow + * probe cannot overlap itself into the next tick. + */ + const SOURCE_STATUS_TIMEOUT_MS = 5000 + + /** + * Last failure message logged per source, so a persistently broken probe + * says so once instead of once per tick forever. + * + * @type {Map} + */ + const sourceProbeFailures = new Map() + + /** @type {Set} */ + const sourceProbesInFlight = new Set() + + /** + * Probe one source's `status()` details under a timeout, and never let the + * plugin's promise outlive our interest in it. A timed-out probe cannot be + * cancelled, so the source is skipped until its previous call settles. + * Otherwise a permanently hung probe would start (and hold open) a fresh + * `source.status` span on every tick for the daemon's life. + * + * @param {string} name + * @returns {Promise<{ details: object | undefined, failure: string | undefined }>} + */ + async function probeSourceDetails(name) { + if (sourceProbesInFlight.has(name)) { + return { details: undefined, failure: 'previous status probe has not settled' } + } + sourceProbesInFlight.add(name) + const settle = () => sourceProbesInFlight.delete(name) + const probe = boot.runtime.sources.status(name).then((s) => s?.details ?? undefined) + probe.then(settle, settle) + /** @type {NodeJS.Timeout | undefined} */ + let timer + try { + const details = await Promise.race([ + probe, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error(`status probe exceeded ${SOURCE_STATUS_TIMEOUT_MS}ms`)), + SOURCE_STATUS_TIMEOUT_MS + ) + if (typeof timer.unref === 'function') timer.unref() + }), + ]) + return { details: /** @type {object | undefined} */ (details), failure: undefined } + } catch (err) { + return { details: undefined, failure: err instanceof Error ? err.message : String(err) } + } finally { + if (timer) clearTimeout(timer) + } + } + + /** + * Report a probe outcome at most once per transition. The refresh runs every + * tick, so logging each failure unconditionally would turn one broken plugin + * into a log line every tick for the daemon's life; logging none at all - the + * old `safeStatus` behaviour - leaves an operator with a source whose details + * silently never change and nothing anywhere saying why. + * + * @param {string} name + * @param {string | undefined} failure + */ + function noteProbeOutcome(name, failure) { + const previous = sourceProbeFailures.get(name) + if (failure === undefined) { + if (previous === undefined) return + sourceProbeFailures.delete(name) + fileLog.info('daemon.source_status_recovered', { hyp_source: name }) + return + } + if (previous === failure) return + sourceProbeFailures.set(name, failure) + fileLog.warn('daemon.source_status_failed', { + hyp_source: name, + message: failure, + error_kind: 'source_status_probe', + }) + } + + /** + * Re-read every started source's `status()` details into the snapshot + * list. Boot writes the details once (`startConfiguredSources`), which + * was enough while every detail was fixed at bind time (host, port, + * fallback marker). It is not enough for details that accrue as traffic + * flows: the gateway's `recent_entrypoints` would be frozen at "nothing + * seen yet" for the daemon's whole life, and `hyp status` reads exactly + * this file. Name, plugin, and state are left alone - liveness is the + * lifecycle's business, not a status probe's. + * + * Best-effort per source: a source whose probe throws, times out, or + * returns nothing keeps the details it already had rather than losing + * them, and one bad source never blocks the next one or the persist + * below. + * + * @ref LLP 0164#status-reads-it-from-the-status-file [implements]: the tick refreshes source details so accruing details reach status.json + */ + async function refreshSourceDetails() { + for (const snap of status.sources) { + if (snap.state !== 'started') continue + const { details, failure } = await probeSourceDetails(snap.name) + if (details !== undefined) snap.details = details + noteProbeOutcome(snap.name, failure) + } + } + async function runTick() { const now = new Date() await withSpan( @@ -502,6 +618,7 @@ export async function runDaemon(opts = {}) { fileLog.error('daemon.tick_failed', { message }) }) status.sinks = collectSinkSnapshots({ runtime: boot.runtime, sinkSnapshots }) + await refreshSourceDetails() persist() } @@ -579,6 +696,11 @@ export async function runDaemon(opts = {}) { // mid-import. Abandoning a pass would orphan the spawned `hyp backfill` // child and interrupt the marker write. await reconcileScheduler.settle() + // Last chance to capture accruing source details: the sources are still + // running here, and after `stopAllSources` below their probes are gone. + // A daemon that never reached a tick (or stopped between ticks) would + // otherwise leave a status file claiming no client was ever seen. + await refreshSourceDetails() persist({ state: 'stopping' }) fileLog.info('daemon.stopping', { reason }) diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js index f389c17b..3ba9369e 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -19,7 +19,7 @@ import { discoverBundledPlugins } from '../runtime/bundled.js' import { buildPluginCatalog } from '../plugin_catalog.js' import { classifyClientProvenance } from '../cli/wizard/provenance.js' import { atomicWriteJsonSync, readFileIfExistsSync } from '../util/fs_atomic.js' -import { getAtDottedPath, isPlainObject } from '../util/json_util.js' +import { getAtDottedPath, isPlainObject, sanitizeLabel } from '../util/json_util.js' import { localOnlyListPath, LocalOnlyListUnreadableError, readLocalOnlyDirs } from '../usage-policy/index.js' import { readFirstSyncDeadline } from '../usage-policy/first_sync_hold.js' import { resolveClientSettingsPath } from './client_settings_path.js' @@ -40,7 +40,7 @@ import { /** * @import { HypAwareV2Config, PluginConfigInstance } from '../../../hypaware-plugin-kernel-types.js' * @import { ClientActionStatus, ConfigControlStatus, ConfigValidationError } from '../../../src/core/config/types.js' - * @import { ClientActionReport, ClientActionsReport, ClientAttachReport, CollectStatusOptions, DaemonStatus, HypAwareStatusReport, ServiceState, SinkSnapshot, SourceSnapshot, StatusDiagnostic } from '../../../src/core/daemon/types.js' + * @import { ClientActionReport, ClientActionsReport, ClientAttachReport, CollectStatusOptions, DaemonStatus, HypAwareStatusReport, RecentEntrypoint, ServiceState, SinkSnapshot, SourceSnapshot, StatusDiagnostic } from '../../../src/core/daemon/types.js' * @import { Dirent } from 'node:fs' * @import { ClientDescriptor, LoadedManifest, PluginCatalog } from '../../../src/core/types.js' */ @@ -124,6 +124,71 @@ export function gatewaySourceDetails(sources) { return { host, port, listenFallback, ...(listenFallbackFrom ? { listenFallbackFrom } : {}) } } +/** + * How many recent client surfaces `hyp status` will report. The gateway keeps + * its own, deliberately equal, cap on the writing side; this one exists + * because core reads a *file* and a file can have been written by an older + * build, a different build, or something that is not this daemon at all. It + * bounds the terminal output, not the tracker. + */ +const MAX_RECENT_ENTRYPOINTS = 32 + +/** + * Lift the gateway source's `recent_entrypoints` detail out of a status-file + * source-snapshot list, most recently seen first. + * + * This is the whole of core's knowledge about client surfaces: it validates + * shape and orders by time, and never interprets an `entrypoint` string. Which + * value means "Codex Desktop" stays Codex's business, exactly as + * [LLP 0130]'s "rendering needs no plugin code" and LLP 0003's core/plugin + * split require. Malformed or partial entries are dropped rather than repaired: + * a name in `hyp status` that no query could reproduce would be worse than a + * short list. + * + * Labels are sanitized here as well as at the gateway that wrote them, and the + * list is capped here as well as there. This is not belt-and-braces: + * `status.json` is a file, and core must not assume the daemon that wrote it + * was this version, was this build, or was well behaved. Everything read here + * is about to be printed to a terminal, so all three ways a label can be + * hostile are answered at the last point before render - control and invisible + * bytes (`sanitizeLabel`), unbounded length (`sanitizeLabel`), and unbounded + * *count*, which the writer's cap does not cover for a file this build did not + * write. + * + * @param {SourceSnapshot[] | undefined} sources + * @returns {RecentEntrypoint[]} + * @ref LLP 0164#status-reads-it-from-the-status-file [implements]: hyp status answers from status.json, with no dataset registry and no cache read + */ +export function recentEntrypointsFromSources(sources) { + const list = Array.isArray(sources) ? sources : [] + const source = + list.find((s) => s && s.plugin === GATEWAY_PLUGIN_NAME) ?? + list.find((s) => s && s.name === 'ai-gateway') + const rawDetails = source && typeof source.details === 'object' ? source.details : undefined + if (!rawDetails) return [] + const raw = /** @type {Record} */ (rawDetails).recent_entrypoints + if (!Array.isArray(raw)) return [] + /** @type {RecentEntrypoint[]} */ + const out = [] + for (const item of raw) { + if (!isPlainObject(item)) continue + const entrypoint = sanitizeLabel(item.entrypoint) + const lastSeen = item.last_seen + if (entrypoint === undefined) continue + if (typeof lastSeen !== 'string' || Number.isNaN(Date.parse(lastSeen))) continue + out.push({ + entrypoint, + clientName: sanitizeLabel(item.client_name) ?? null, + lastSeen, + rows: typeof item.rows === 'number' && Number.isFinite(item.rows) ? item.rows : 0, + }) + } + out.sort((a, b) => (a.lastSeen < b.lastSeen ? 1 : a.lastSeen > b.lastSeen ? -1 : 0)) + // Sorted before the cap so the entries kept are the most recently seen ones, + // which is the same entry a "recent clients" readout would keep anyway. + return out.slice(0, MAX_RECENT_ENTRYPOINTS) +} + /** * Resolve the AI gateway's live bound base URL from the on-disk daemon status * snapshot, **guarded by a daemon-liveness check** so a stale snapshot from a @@ -450,6 +515,18 @@ export async function collectHypAwareStatus(opts = {}) { sources.push(...inferConfiguredSources(activePlugins)) } + // ----- recent client surfaces (LLP 0164) ----- + // Read from the status file specifically, never from `sources` above: the + // daemon is the only process traffic flows through, so an in-process + // gateway source booted by this very CLI call has by definition seen + // nothing. It is deliberately NOT gated on daemon liveness the way + // `resolveLiveGatewayEndpointFromStatus` is - a bound port is a claim + // about now and goes stale the moment the daemon dies, whereas "last seen + // at T" stays true afterwards, and the rendered age makes staleness + // self-evident. + // @ref LLP 0164#not-liveness-gated [implements]: a last-seen timestamp survives its daemon; the rendered age carries the staleness + const recentEntrypoints = recentEntrypointsFromSources(daemonStatusFile?.sources) + // Sinks are derived from the loaded config (so the count reflects // "how many sinks does the user have configured?", the same number // a fresh kernel boot or a running daemon would surface). When @@ -720,6 +797,7 @@ export async function collectHypAwareStatus(opts = {}) { clientActions, usagePolicy, firstSyncHoldDeadline, + recentEntrypoints, } } diff --git a/src/core/daemon/types.d.ts b/src/core/daemon/types.d.ts index 957cd260..f31ce53d 100644 --- a/src/core/daemon/types.d.ts +++ b/src/core/daemon/types.d.ts @@ -31,6 +31,25 @@ export interface SourceSnapshot { details?: object } +/** + * One client surface the running gateway has produced rows for, lifted + * out of the gateway source's `status()` details in `status.json`. + * + * `entrypoint` is verbatim whatever the projector wrote into the row's + * `entrypoint` column (Codex owns `codex-tui` and whatever Codex Desktop + * reports; Claude Desktop's 3p route reports `local-agent`). Core never + * interprets it, which is what keeps this surface client-agnostic. + */ +export interface RecentEntrypoint { + entrypoint: string + /** `client_name` last seen with this entrypoint, or null when unset. */ + clientName: string | null + /** ISO timestamp of the most recent row projected under it. */ + lastSeen: string + /** Rows projected under it since the daemon booted. */ + rows: number +} + export interface SinkSnapshot { instance: string plugin: string @@ -250,6 +269,14 @@ export interface HypAwareStatusReport { * holding ticks. A held machine must never be a silent state (LLP 0100 R9). */ firstSyncHoldDeadline: number | null + /** + * Client surfaces the running (or last-run) daemon's gateway produced rows + * for, most recent first, read from `status.json` (LLP 0164). Empty when no + * daemon has run for this state root, when the gateway recorded nothing, or + * when the daemon predates the tracker - `hyp status` activates no plugins + * and reads no cache, so this is the only place the answer can come from. + */ + recentEntrypoints: RecentEntrypoint[] } export interface CollectStatusOptions { diff --git a/src/core/util/index.js b/src/core/util/index.js index 1fe02bcd..f2e24c6b 100644 --- a/src/core/util/index.js +++ b/src/core/util/index.js @@ -16,11 +16,13 @@ export { } from './fs_atomic.js' export { copyDir } from './fs_copy.js' export { + MAX_LABEL_CHARS, VOLATILE_BLOCK_FIELDS, canonicalJson, errCode, isPlainObject, parseMaybeJson, + sanitizeLabel, sha256Hex, sortKeys, stringValue, diff --git a/src/core/util/json_util.js b/src/core/util/json_util.js index 04d5a92a..e0eb2603 100644 --- a/src/core/util/json_util.js +++ b/src/core/util/json_util.js @@ -26,6 +26,80 @@ export function stringValue(value) { return typeof value === 'string' && value.length > 0 ? value : undefined } +/** + * Default ceiling for a display label lifted off captured data. Long + * enough that no real client surface is truncated (`local-agent`, + * `codex-tui`, `Codex Desktop` are all well under it), short enough that + * a status file holding a bounded number of them stays small. + */ +export const MAX_LABEL_CHARS = 120 + +// Everything a label may contain that either drives the terminal or +// occupies no width on it. Three groups, one class: +// +// - C0/DEL/C1 and the Unicode line/paragraph separators, so a label can +// never move the cursor, erase a line, open an escape sequence, or +// split into a second line. +// - Bidirectional formatting (embeddings, overrides, isolates, marks). +// These print nothing but reorder what follows, and an unterminated +// one keeps reordering past the end of the label into the rest of the +// status line. A label that renders as a different string than the one +// it stores defeats the point of naming a surface at all. +// - Zero-width and default-ignorable formatting (ZWSP/ZWNJ/ZWJ, word +// joiner, BOM, soft hyphen, variation selectors). These render as +// nothing, so keeping them lets two labels be distinct map keys while +// being indistinguishable on screen, which is what would otherwise +// dilute the tracker's eviction cap. +// +// Confusables are deliberately out of scope: this bounds what a label +// *does*, not what it looks like. Two labels built from different but +// similar-looking real letters stay distinct, as they must. +const UNSAFE_LABEL_CHARS = + /[\u0000-\u001F\u007F-\u009F\u00AD\u061C\u180E\u200B-\u200F\u2028-\u2029\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFE00-\uFE0F\uFEFF]/g + +// A high surrogate left stranded by the clamp below. Slicing counts UTF-16 +// code units, so a cut can land between the halves of an astral character +// and leave behind a string that is not well-formed. +const TRAILING_HIGH_SURROGATE = /[\uD800-\uDBFF]$/ + +const TRUNCATION_MARKER = '...' + +/** + * Make a captured string safe to write into a status file and print to a + * terminal: strip control and invisible formatting characters, and clamp + * the length. + * + * Values like `entrypoint` are captured verbatim from whatever the client + * put on the wire or wrote into a transcript file on disk, so they carry + * no guarantee of being short, printable, or single-line. A raw value + * reaching a TTY lets a client repaint the operator's screen (an `ESC` + * sequence, or a newline that forges a plausible extra status line), + * reorder it (a bidi override), or hide inside it (a zero-width run), and + * an arbitrarily long one bloats every file the label lands in. None of + * this is hypothetical: transcript-sourced values are ordinary JSON + * strings with no parser bounding them. + * + * The result is at most `max` characters *including* the truncation + * marker, which is a plain ASCII ellipsis so the result stays + * single-byte-safe in a terminal. `max` counts UTF-16 code units, not + * bytes: a label of astral characters is still roughly 4x `max` bytes + * once encoded, which is why callers cap the count of labels too. + * + * @param {unknown} value + * @param {number} [max] + * @returns {string | undefined} Cleaned non-empty string, else `undefined`. + */ +export function sanitizeLabel(value, max = MAX_LABEL_CHARS) { + if (typeof value !== 'string' || value.length === 0) return undefined + const stripped = value.replace(UNSAFE_LABEL_CHARS, '') + if (stripped.length === 0) return undefined + if (stripped.length <= max) return stripped + const head = stripped + .slice(0, Math.max(0, max - TRUNCATION_MARKER.length)) + .replace(TRAILING_HIGH_SURROGATE, '') + return head.length === 0 ? undefined : `${head}${TRUNCATION_MARKER}` +} + /** * Parse `value` as JSON when it is a string, falling back to the * original value when it is not a string or does not parse. Projectors diff --git a/test/core/daemon-source-details-refresh.test.js b/test/core/daemon-source-details-refresh.test.js new file mode 100644 index 00000000..d54ddb36 --- /dev/null +++ b/test/core/daemon-source-details-refresh.test.js @@ -0,0 +1,275 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { runDaemon } from '../../src/core/daemon/runtime.js' +import { readStatusFile } from '../../src/core/daemon/status.js' +import { defaultConfigPath } from '../../src/core/config/schema.js' +import { writeLock } from '../../src/core/plugin_install/lock.js' + +// Boot wrote each source's `status()` details exactly once, which was enough +// while every detail was fixed at bind time. It is not enough for details that +// accrue as traffic flows: the gateway's `recent_entrypoints` would be frozen +// at "nothing seen yet" for the daemon's whole life, and `hyp status` reads +// exactly this file. +// @ref LLP 0164#status-reads-it-from-the-status-file [tests]: + +const PLUGIN = '@third-party/accruing-fixture' + +/** + * Stage a plugin whose source's `status()` details change on every call, the + * way the gateway's do as exchanges land. + * + * @param {string} hypHome + * @returns {Promise} + */ +async function stageAccruingPlugin(hypHome) { + const installDir = path.join(hypHome, 'hypaware', 'plugins', PLUGIN) + await fs.mkdir(installDir, { recursive: true }) + await fs.writeFile(path.join(installDir, 'hypaware.plugin.json'), JSON.stringify({ + schema_version: 1, + name: PLUGIN, + version: '0.1.0', + hypaware_api: '^1.0.0', + runtime: 'node', + entrypoint: './index.js', + })) + await fs.writeFile( + path.join(installDir, 'index.js'), + ` +export async function activate(ctx) { + ctx.sources.register({ + name: 'accruing-fixture', + plugin: '${PLUGIN}', + async start() { + let probes = 0 + return { + async status() { + probes += 1 + return { state: 'ready', details: { probes, seen: probes > 1 ? ['late-arrival'] : [] } } + }, + async stop() {}, + } + }, + }) +} +` + ) + return installDir +} + +/** + * @param {string} hypHome + * @param {string} installDir + */ +async function writeInstall(hypHome, installDir) { + await writeLock(path.join(hypHome, 'hypaware'), { + schema_version: 1, + plugins: { + [PLUGIN]: { + name: PLUGIN, + version: '0.1.0', + source: { kind: 'local-dir', raw: installDir, path: installDir }, + install_dir: installDir, + content_hash: 'a'.repeat(64), + manifest_hash: 'b'.repeat(64), + installed_at: '2026-07-30T00:00:00.000Z', + }, + }, + }) + const configPath = defaultConfigPath(hypHome) + await fs.mkdir(path.dirname(configPath), { recursive: true }) + await fs.writeFile(configPath, JSON.stringify({ + version: 2, + plugins: [{ name: PLUGIN, config: {} }], + })) + return configPath +} + +test('the daemon refreshes source details on every tick, so accruing details reach status.json', async () => { + const hypHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-source-details-tick-')) + const stateRoot = path.join(hypHome, 'hypaware') + let handle + try { + const configPath = await writeInstall(hypHome, await stageAccruingPlugin(hypHome)) + handle = await runDaemon({ + hypHome, + configPath, + env: { ...process.env, HYP_HOME: hypHome }, + runId: 'source-details-tick', + // The floor the daemon clamps to, so a tick lands inside the wait below. + tickIntervalMs: 1, + installSignalHandlers: false, + }) + + const atBoot = readStatusFile(stateRoot) + assert.ok(atBoot) + assert.deepEqual(atBoot.sources[0].details, { probes: 1, seen: [] }) + + const deadline = Date.now() + 20_000 + /** @type {any} */ + let details + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)) + const snapshot = readStatusFile(stateRoot) + details = snapshot?.sources?.[0]?.details + if (details && /** @type {any} */ (details).probes > 1) break + } + assert.ok(details, 'no source snapshot on disk') + assert.ok(details.probes > 1, `source details never refreshed (probes=${details.probes})`) + assert.deepEqual(details.seen, ['late-arrival']) + } finally { + if (handle) { + await handle.stop() + await handle.done + } + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) + +test('a daemon that never reached a tick still refreshes source details before it stops', async () => { + const hypHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-source-details-stop-')) + const stateRoot = path.join(hypHome, 'hypaware') + let handle + try { + const configPath = await writeInstall(hypHome, await stageAccruingPlugin(hypHome)) + handle = await runDaemon({ + hypHome, + configPath, + env: { ...process.env, HYP_HOME: hypHome }, + runId: 'source-details-stop', + // No tick loop at all: the shutdown refresh is the only chance. + tickIntervalMs: 0, + installSignalHandlers: false, + }) + + await handle.stop() + await handle.done + handle = undefined + + const final = readStatusFile(stateRoot) + assert.ok(final) + assert.deepEqual(final.sources[0].details, { probes: 2, seen: ['late-arrival'] }) + // Liveness is still the lifecycle's business, not the probe's. + assert.equal(final.sources[0].state, 'stopped') + } finally { + if (handle) { + await handle.stop() + await handle.done + } + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) + +// The refresh put plugin code on the tick loop's critical path, and the +// kernel contract puts no bound on `status()`. A probe that never settles +// used to be able to hang only boot, which is at least loud. On the tick path +// it froze `persist()` - so *every* field in `status.json` went stale, not +// just that source's - and hung the shutdown refresh with it, all while the +// daemon went on reporting itself healthy. +// @ref LLP 0164#status-reads-it-from-the-status-file [tests]: + +/** + * Stage a plugin whose source answers `status()` once, at boot, and then + * never again. + * + * @param {string} hypHome + * @returns {Promise} + */ +async function stageHangingPlugin(hypHome) { + const installDir = path.join(hypHome, 'hypaware', 'plugins', PLUGIN) + await fs.mkdir(installDir, { recursive: true }) + await fs.writeFile(path.join(installDir, 'hypaware.plugin.json'), JSON.stringify({ + schema_version: 1, + name: PLUGIN, + version: '0.1.0', + hypaware_api: '^1.0.0', + runtime: 'node', + entrypoint: './index.js', + })) + await fs.writeFile( + path.join(installDir, 'index.js'), + ` +export async function activate(ctx) { + ctx.sources.register({ + name: 'accruing-fixture', + plugin: '${PLUGIN}', + async start() { + let probes = 0 + return { + async status() { + probes += 1 + if (probes > 1) await new Promise(() => {}) + return { state: 'ready', details: { probes } } + }, + async stop() {}, + } + }, + }) +} +` + ) + return installDir +} + +test('a source whose status() never settles cannot freeze the status file or the shutdown', async () => { + const hypHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-source-details-hang-')) + const stateRoot = path.join(hypHome, 'hypaware') + let handle + try { + const configPath = await writeInstall(hypHome, await stageHangingPlugin(hypHome)) + handle = await runDaemon({ + hypHome, + configPath, + env: { ...process.env, HYP_HOME: hypHome }, + runId: 'source-details-hang', + tickIntervalMs: 1, + installSignalHandlers: false, + }) + + const statusPath = path.join(stateRoot, 'run', 'status.json') + const firstWrite = (await fs.stat(statusPath)).mtimeMs + + // The probe is hung from the very first tick. The tick loop must still be + // rewriting the file: a frozen mtime here is the whole bug. + const deadline = Date.now() + 20_000 + let advanced = false + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)) + if ((await fs.stat(statusPath)).mtimeMs > firstWrite) { + advanced = true + break + } + } + assert.ok(advanced, 'status.json stopped being written while a probe hung') + + // Details keep their last good value rather than being lost. + const snapshot = readStatusFile(stateRoot) + assert.deepEqual(snapshot?.sources?.[0]?.details, { probes: 1 }) + + // ... and the daemon can still be stopped. + const stopped = await Promise.race([ + handle.stop().then(() => handle.done).then(() => 'stopped'), + new Promise((resolve) => setTimeout(() => resolve('hung'), 15_000)), + ]) + handle = undefined + assert.equal(stopped, 'stopped', 'shutdown hung on the source status probe') + + // Silence was the other half of the bug: say it, but say it once, not + // once per tick for the daemon's life. + const log = await fs.readFile(path.join(stateRoot, 'logs', 'daemon.log'), 'utf8') + const failures = log.split(String.fromCharCode(10)).filter((l) => l.includes('daemon.source_status_failed')) + assert.ok(failures.length > 0, 'a stuck status probe was never reported') + assert.ok(failures.length <= 4, `status probe failure logged ${failures.length} times`) + } finally { + if (handle) { + await handle.stop() + await handle.done + } + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) diff --git a/test/core/status-recent-entrypoints.test.js b/test/core/status-recent-entrypoints.test.js new file mode 100644 index 00000000..07c79b98 --- /dev/null +++ b/test/core/status-recent-entrypoints.test.js @@ -0,0 +1,365 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { + collectHypAwareStatus, + recentEntrypointsFromSources, + writeStatusFile, +} from '../../src/core/daemon/status.js' +import { writePidFile } from '../../src/core/daemon/pid.js' +import { + formatEntrypointAge, + renderStatusJson, + renderStatusText, +} from '../../src/core/commands/status.js' +import { defaultConfigPath } from '../../src/core/config/schema.js' + +/** @import { CollectStatusOptions } from '../../src/core/daemon/types.js' */ + +// `hyp status` must be able to say "Codex Desktop traffic arrived N minutes +// ago" without activating a plugin and without reading the cache. The only +// place that answer can come from is the gateway's own status-file details. +// @ref LLP 0164#status-reads-it-from-the-status-file [tests]: + +async function makeHome() { + const hypHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hyp-status-entrypoints-')) + const stateRoot = path.join(hypHome, 'hypaware') + await fs.mkdir(path.join(stateRoot, 'run'), { recursive: true }) + await fs.writeFile(defaultConfigPath(hypHome), JSON.stringify({ version: 2, plugins: [] }) + '\n') + return { hypHome, stateRoot } +} + +/** + * @param {string} stateRoot + * @param {Record} details + * @param {{ alive?: boolean }} [opts] + */ +function writeDaemonStatus(stateRoot, details, opts = {}) { + if (opts.alive !== false) { + writePidFile(stateRoot, /** @type {any} */ ({ pid: process.pid, runId: 'test-run', mode: 'foreground' })) + } + writeStatusFile(stateRoot, /** @type {any} */ ({ + state: 'healthy', + sources: [{ name: 'ai-gateway', plugin: '@hypaware/ai-gateway', state: 'started', details }], + sinks: [], + })) +} + +/** + * @param {string} hypHome + * @returns {CollectStatusOptions} + */ +function collectOpts(hypHome) { + return { + env: { ...process.env, HYP_HOME: hypHome, HYP_CONFIG: '' }, + platform: 'darwin', + isLaunchAgentInstalled: () => false, + } +} + +/** @returns {{ write(chunk: string): void, text(): string }} */ +function buffer() { + /** @type {string[]} */ + const chunks = [] + return { write: (chunk) => { chunks.push(chunk) }, text: () => chunks.join('') } +} + +test('recentEntrypointsFromSources lifts the gateway detail, newest first', () => { + const out = recentEntrypointsFromSources(/** @type {any} */ ([ + { + name: 'ai-gateway', + plugin: '@hypaware/ai-gateway', + details: { + host: '127.0.0.1', + port: 18521, + recent_entrypoints: [ + { entrypoint: 'codex-tui', client_name: 'codex', last_seen: '2026-07-30T09:00:00.000Z', rows: 40 }, + { entrypoint: 'Codex Desktop', client_name: 'codex', last_seen: '2026-07-30T11:00:00.000Z', rows: 6 }, + ], + }, + }, + ])) + assert.deepEqual(out, [ + { entrypoint: 'Codex Desktop', clientName: 'codex', lastSeen: '2026-07-30T11:00:00.000Z', rows: 6 }, + { entrypoint: 'codex-tui', clientName: 'codex', lastSeen: '2026-07-30T09:00:00.000Z', rows: 40 }, + ]) +}) + +test('a malformed or partial entry is dropped, not repaired into a name no query can reproduce', () => { + const out = recentEntrypointsFromSources(/** @type {any} */ ([ + { + name: 'ai-gateway', + plugin: '@hypaware/ai-gateway', + details: { + recent_entrypoints: [ + null, + 'codex-tui', + { client_name: 'codex', last_seen: '2026-07-30T11:00:00.000Z' }, + { entrypoint: 'codex-tui', last_seen: 'not a date' }, + { entrypoint: 'local-agent', last_seen: '2026-07-30T11:00:00.000Z' }, + ], + }, + }, + ])) + assert.deepEqual(out, [ + { entrypoint: 'local-agent', clientName: null, lastSeen: '2026-07-30T11:00:00.000Z', rows: 0 }, + ]) +}) + +test('a daemon with no gateway source, or an older daemon, yields an empty list', () => { + assert.deepEqual(recentEntrypointsFromSources(undefined), []) + assert.deepEqual(recentEntrypointsFromSources(/** @type {any} */ ([])), []) + assert.deepEqual( + recentEntrypointsFromSources(/** @type {any} */ ([ + { name: 'ai-gateway', plugin: '@hypaware/ai-gateway', state: 'started', details: { host: '127.0.0.1', port: 18521 } }, + ])), + [] + ) +}) + +test('hyp status surfaces Codex Desktop traffic from status.json, with no cache read', async () => { + const { hypHome, stateRoot } = await makeHome() + try { + writeDaemonStatus(stateRoot, { + host: '127.0.0.1', + port: 18521, + recent_entrypoints: [ + { entrypoint: 'Codex Desktop', client_name: 'codex', last_seen: new Date(Date.now() - 5 * 60_000).toISOString(), rows: 6 }, + { entrypoint: 'codex-tui', client_name: 'codex', last_seen: new Date(Date.now() - 3 * 3_600_000).toISOString(), rows: 40 }, + ], + }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + assert.deepEqual(report.recentEntrypoints.map((e) => e.entrypoint), ['Codex Desktop', 'codex-tui']) + + const stdout = buffer() + renderStatusText({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(stateRoot, 'cache'), + stdout, + }) + const text = stdout.text() + assert.match(text, /recent clients:/) + assert.match(text, /- Codex Desktop {2}\(codex\) {2}last seen 5m ago, 6 rows/) + assert.match(text, /- codex-tui {2}\(codex\) {2}last seen 3h ago, 40 rows/) + + const json = renderStatusJson({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(stateRoot, 'cache'), + }) + assert.equal(json.recent_entrypoints.length, 2) + assert.equal(json.recent_entrypoints[0].entrypoint, 'Codex Desktop') + assert.equal(json.recent_entrypoints[0].client_name, 'codex') + assert.equal(json.recent_entrypoints[0].rows, 6) + } finally { + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) + +test('an install that has never captured keeps the V1 text surface unchanged', async () => { + const { hypHome, stateRoot } = await makeHome() + try { + writeDaemonStatus(stateRoot, { host: '127.0.0.1', port: 18521, recent_entrypoints: [] }) + const report = await collectHypAwareStatus(collectOpts(hypHome)) + assert.deepEqual(report.recentEntrypoints, []) + + const stdout = buffer() + renderStatusText({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(stateRoot, 'cache'), + stdout, + }) + assert.doesNotMatch(stdout.text(), /recent clients/) + + const json = renderStatusJson({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(stateRoot, 'cache'), + }) + assert.deepEqual(json.recent_entrypoints, []) + } finally { + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) + +test('last-seen survives its daemon: a stopped daemon still reports what it saw', async () => { + const { hypHome, stateRoot } = await makeHome() + try { + // No pid file: nothing is running. Unlike a bound port, "last seen at T" + // does not become false when the daemon exits, and the rendered age + // carries the staleness. + writeDaemonStatus(stateRoot, { + recent_entrypoints: [ + { entrypoint: 'Codex Desktop', client_name: 'codex', last_seen: new Date(Date.now() - 2 * 86_400_000).toISOString(), rows: 6 }, + ], + }, { alive: false }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + assert.deepEqual(report.recentEntrypoints.map((e) => e.entrypoint), ['Codex Desktop']) + + const stdout = buffer() + renderStatusText({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(stateRoot, 'cache'), + stdout, + }) + assert.match(stdout.text(), /- Codex Desktop {2}\(codex\) {2}last seen 2d ago, 6 rows/) + } finally { + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) + +test('formatEntrypointAge is coarse, and never renders a negative age', () => { + const now = Date.parse('2026-07-30T12:00:00.000Z') + assert.equal(formatEntrypointAge('2026-07-30T11:59:30.000Z', now), 'just now') + assert.equal(formatEntrypointAge('2026-07-30T11:55:00.000Z', now), '5m ago') + assert.equal(formatEntrypointAge('2026-07-30T09:00:00.000Z', now), '3h ago') + assert.equal(formatEntrypointAge('2026-07-28T12:00:00.000Z', now), '2d ago') + // Clock went backwards after the status file was written. + assert.equal(formatEntrypointAge('2026-07-30T13:00:00.000Z', now), 'just now') + assert.equal(formatEntrypointAge('not a date', now), 'at an unreadable time') +}) + +test('a client with no client_name renders without inventing one', async () => { + const { hypHome, stateRoot } = await makeHome() + try { + writeDaemonStatus(stateRoot, { + recent_entrypoints: [ + { entrypoint: 'local-agent', last_seen: new Date().toISOString(), rows: 1 }, + ], + }) + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const stdout = buffer() + renderStatusText({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(stateRoot, 'cache'), + stdout, + }) + assert.match(stdout.text(), /- local-agent {2}last seen just now, 1 row\n/) + } finally { + await fs.rm(hypHome, { recursive: true, force: true }) + } +}) + +// `status.json` is a file. Core must not assume the daemon that wrote it was +// this version or was well behaved, and everything read here is about to be +// printed to a terminal. +test('recentEntrypointsFromSources cleans labels a foreign status file supplies', () => { + const ESC = String.fromCharCode(27) + const LF = String.fromCharCode(10) + const recent = recentEntrypointsFromSources([ + { + name: 'ai-gateway', + plugin: '@hypaware/ai-gateway', + state: 'started', + details: { + recent_entrypoints: [ + { + entrypoint: `local-agent${ESC}[2K${LF} daemon: FORGED ALL GOOD`, + client_name: `claude${LF} x`, + last_seen: '2026-07-30T09:55:00.000Z', + rows: 3, + }, + { + entrypoint: 'C'.repeat(40000), + client_name: null, + last_seen: '2026-07-30T09:50:00.000Z', + rows: 1, + }, + ], + }, + }, + ]) + + assert.equal(recent.length, 2) + for (const e of recent) { + assert.equal(e.entrypoint.includes(ESC), false, 'no escape byte survives') + assert.equal(e.entrypoint.includes(LF), false, 'no newline survives') + assert.ok(e.entrypoint.length <= 128, `length ${e.entrypoint.length}`) + assert.equal((e.clientName ?? '').includes(LF), false) + } + assert.match(recent[0].entrypoint, /^local-agent/) +}) + +test('a rendered recent-clients block cannot be forged by a hostile entrypoint', async () => { + const ESC = String.fromCharCode(27) + const LF = String.fromCharCode(10) + const { hypHome, stateRoot } = await makeHome() + writeDaemonStatus(stateRoot, { + host: '127.0.0.1', + port: 18521, + recent_entrypoints: [ + { + entrypoint: `x${LF} daemon: FORGED${LF} cache: /nowhere${ESC}[2K`, + client_name: 'claude', + last_seen: new Date().toISOString(), + rows: 1, + }, + ], + }) + + const report = await collectHypAwareStatus(collectOpts(hypHome)) + const stdout = buffer() + renderStatusText({ + report, + clientNames: [], + datasets: [], + cacheRoot: path.join(stateRoot, 'cache'), + stdout, + }) + const text = stdout.text() + + // The surface is still named, on exactly one line, and nothing it contained + // became a line of its own or an escape sequence. + assert.match(text, /recent clients:/) + assert.equal(text.includes(ESC), false, 'no escape byte reaches the terminal') + assert.equal(text.includes(`${LF} daemon: FORGED`), false, 'no forged daemon line') + // The whole payload collapses onto the single entry line, where it is inert + // text inside the `- () last seen ...` shape rather + // than a line of its own. + const block = text.slice(text.indexOf(' recent clients:')).split(LF) + assert.match(block[1], /^ {4}- \S.* {2}\(claude\) {2}last seen just now, 1 row$/) + assert.match(block[2], /^ {2}cache:/, 'the next line is the real one, not a forged one') +}) + +// `sanitizeLabel` bounds a label's bytes; nothing bounded how many of them +// core would read back. The gateway caps its own map, but core reads a *file*, +// and the same sentence that justifies sanitizing on read - this build did not +// necessarily write it - applies to the count. +test('a status file with an absurd number of entrypoints is capped on read', () => { + const many = Array.from({ length: 5000 }, (_, i) => ({ + entrypoint: `surface-${i}`, + client_name: null, + last_seen: new Date(Date.now() - i * 1000).toISOString(), + rows: 1, + })) + + const recent = recentEntrypointsFromSources([ + { + name: 'ai-gateway', + plugin: '@hypaware/ai-gateway', + state: 'started', + details: { recent_entrypoints: many }, + }, + ]) + + assert.equal(recent.length, 32) + // Capped after the sort, so what survives is the most recently seen. + assert.equal(recent[0].entrypoint, 'surface-0') +}) diff --git a/test/plugins/ai-gateway-entrypoint-activity.test.js b/test/plugins/ai-gateway-entrypoint-activity.test.js new file mode 100644 index 00000000..5362fece --- /dev/null +++ b/test/plugins/ai-gateway-entrypoint-activity.test.js @@ -0,0 +1,193 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { createEntrypointActivity } from '../../hypaware-core/plugins-workspace/ai-gateway/src/entrypoint_activity.js' + +// The gateway's half of "hyp status names recent client surfaces": count and +// timestamp whatever the projector wrote into `entrypoint`, interpret none of +// it, and never grow without bound on a client-supplied string. +// @ref LLP 0164#gateway-tracks-what-core-cannot-name [tests]: + +test('records one entry per entrypoint, counting rows and stamping last-seen', () => { + let clock = Date.parse('2026-07-30T10:00:00.000Z') + const activity = createEntrypointActivity({ now: () => clock }) + + activity.record([ + { entrypoint: 'codex-tui', client_name: 'codex' }, + { entrypoint: 'codex-tui', client_name: 'codex' }, + ]) + clock += 60_000 + activity.record([{ entrypoint: 'Codex Desktop', client_name: 'codex' }]) + + assert.deepEqual(activity.snapshot(), [ + { + entrypoint: 'Codex Desktop', + client_name: 'codex', + last_seen: '2026-07-30T10:01:00.000Z', + rows: 1, + }, + { + entrypoint: 'codex-tui', + client_name: 'codex', + last_seen: '2026-07-30T10:00:00.000Z', + rows: 2, + }, + ]) +}) + +test('the snapshot is most-recently-seen first, so a stale surface sinks', () => { + let clock = Date.parse('2026-07-30T10:00:00.000Z') + const activity = createEntrypointActivity({ now: () => clock }) + + activity.record([{ entrypoint: 'Codex Desktop', client_name: 'codex' }]) + clock += 3_600_000 + activity.record([{ entrypoint: 'codex-tui', client_name: 'codex' }]) + clock += 3_600_000 + activity.record([{ entrypoint: 'Codex Desktop', client_name: 'codex' }]) + + assert.deepEqual( + activity.snapshot().map((e) => e.entrypoint), + ['Codex Desktop', 'codex-tui'] + ) + assert.equal(activity.snapshot()[0].rows, 2) +}) + +test('rows with no entrypoint are ignored rather than bucketed under a placeholder', () => { + const activity = createEntrypointActivity() + activity.record([ + { client_name: 'codex' }, + { entrypoint: '', client_name: 'codex' }, + { entrypoint: 42, client_name: 'codex' }, + { entrypoint: 'codex-tui' }, + ]) + + const snapshot = activity.snapshot() + assert.equal(snapshot.length, 1) + assert.equal(snapshot[0].entrypoint, 'codex-tui') + // No client_name on the row means none is invented for the status line. + assert.equal(snapshot[0].client_name, null) +}) + +test('a client-supplied entrypoint cannot grow the map without bound', () => { + const activity = createEntrypointActivity({ max: 3 }) + for (let i = 0; i < 50; i++) { + activity.record([{ entrypoint: `surface-${i}`, client_name: 'codex' }]) + } + assert.equal(activity.size(), 3) + // The survivors are the most recently seen, which is what a "recent + // clients" readout would have shown anyway. + assert.deepEqual( + activity.snapshot().map((e) => e.entrypoint).sort(), + ['surface-47', 'surface-48', 'surface-49'] + ) +}) + +test('seeing an old entrypoint again rescues it from eviction', () => { + const activity = createEntrypointActivity({ max: 2 }) + activity.record([{ entrypoint: 'Codex Desktop', client_name: 'codex' }]) + activity.record([{ entrypoint: 'codex-tui', client_name: 'codex' }]) + activity.record([{ entrypoint: 'Codex Desktop', client_name: 'codex' }]) + activity.record([{ entrypoint: 'local-agent', client_name: 'claude' }]) + + assert.deepEqual( + activity.snapshot().map((e) => e.entrypoint).sort(), + ['Codex Desktop', 'local-agent'] + ) +}) + +test('an empty or malformed batch is a no-op, not a throw', () => { + const activity = createEntrypointActivity() + activity.record([]) + activity.record(/** @type {any} */ (undefined)) + activity.record(/** @type {any} */ ([null, 'nope', 7])) + assert.deepEqual(activity.snapshot(), []) +}) + +// The tracker feeds a file on disk and a terminal, and one of the three routes +// into `entrypoint` (Claude's transcript `.jsonl`, via `assignTranscriptIdentity`) +// is not bounded by an HTTP parser: it is a JSON string of any length holding any +// byte. A count cap alone does not bound that. +test('a control-character entrypoint cannot forge lines or move the cursor', () => { + const ESC = String.fromCharCode(27) + const LF = String.fromCharCode(10) + const activity = createEntrypointActivity() + activity.record([ + { entrypoint: `local-agent${ESC}[2K${LF} daemon: FORGED`, client_name: `cl${ESC}[31maude` }, + ]) + + const [entry] = activity.snapshot() + assert.equal(entry.entrypoint.includes(ESC), false) + assert.equal(entry.entrypoint.includes(LF), false) + assert.equal(entry.client_name?.includes(ESC), false) + // Stripped, not dropped: the surface is still named. + assert.match(entry.entrypoint, /^local-agent/) +}) + +test('an over-long entrypoint is clamped, so status.json stays small', () => { + const activity = createEntrypointActivity() + activity.record([{ entrypoint: 'A'.repeat(50000), client_name: 'B'.repeat(50000) }]) + + const [entry] = activity.snapshot() + assert.ok(entry.entrypoint.length <= 128, `entrypoint length ${entry.entrypoint.length}`) + assert.ok((entry.client_name ?? '').length <= 128) + assert.ok(JSON.stringify(activity.snapshot()).length < 1024) +}) + +test('an entrypoint of nothing but control bytes is skipped, not stored blank', () => { + const activity = createEntrypointActivity() + activity.record([{ entrypoint: String.fromCharCode(27) + String.fromCharCode(7) }]) + assert.deepEqual(activity.snapshot(), []) +}) + +// Pins the shipped default. The eviction tests above all pass an explicit +// `max`, so without this the constant could be raised to any value and every +// test would still pass. +test('the default cap is 32 distinct entrypoints', () => { + const activity = createEntrypointActivity() + for (let i = 0; i < 200; i++) activity.record([{ entrypoint: `surface-${i}` }]) + assert.equal(activity.size(), 32) +}) + +// A control byte is not the only way a label can render as something other +// than what it stores. Bidi overrides print nothing and reverse what follows, +// and an unterminated one runs past the end of the label into the rest of the +// status line. +test('a bidi override cannot reorder the rendered status line', () => { + const RLO = String.fromCharCode(0x202e) + const LRI = String.fromCharCode(0x2066) + const activity = createEntrypointActivity() + activity.record([{ entrypoint: `local-agent${RLO}tnega-lacol`, client_name: `c${LRI}laude` }]) + + const [entry] = activity.snapshot() + assert.equal(entry.entrypoint.includes(RLO), false) + assert.equal(entry.client_name?.includes(LRI), false) + assert.match(entry.entrypoint, /^local-agent/) +}) + +// The whole reason the tracker sanitizes at `record` rather than at render is +// to keep the map *key* clean. Characters with no rendered width would +// otherwise mint unlimited distinct keys that an operator cannot tell apart, +// so 32 slots of one surface would evict every real one. +test('invisible characters cannot dilute the eviction cap', () => { + const ZWSP = String.fromCharCode(0x200b) + const activity = createEntrypointActivity() + for (let i = 0; i < 500; i++) activity.record([{ entrypoint: 'codex-tui' + ZWSP.repeat(i) }]) + + assert.equal(activity.size(), 1) + assert.deepEqual(activity.snapshot().map((e) => e.entrypoint), ['codex-tui']) +}) + +// The clamp counts UTF-16 code units, so a cut can land between the halves of +// an astral character. A lone surrogate is not a well-formed string and would +// ride into `status.json` as one. +test('clamping an astral entrypoint leaves a well-formed string', () => { + const activity = createEntrypointActivity() + activity.record([{ entrypoint: 'A'.repeat(119) + String.fromCodePoint(0x1f600).repeat(50) }]) + + const [entry] = activity.snapshot() + assert.equal(entry.entrypoint.isWellFormed(), true) + // The marker is inside the ceiling, not bolted on past it. + assert.ok(entry.entrypoint.length <= 120, `length ${entry.entrypoint.length}`) +})