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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions docs/ACCEPTANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
110 changes: 110 additions & 0 deletions hypaware-core/plugins-workspace/ai-gateway/src/entrypoint_activity.js
Original file line number Diff line number Diff line change
@@ -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<string, { clientName: string | null, lastSeenMs: number, rows: number }>} */
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<string, unknown>[]} 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
},
}
}
27 changes: 24 additions & 3 deletions hypaware-core/plugins-workspace/ai-gateway/src/source.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -36,8 +37,17 @@ export function createStartSource(state) {
* @returns {Promise<StartedSource>}
*/
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<typeof createEntrypointActivity> }} */
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)

Expand All @@ -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
Expand Down Expand Up @@ -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<typeof createEntrypointActivity> }} liveState
* @returns {Promise<StartedProxy>}
*/
async function launchListener(ctx, state, liveState) {
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions hypaware-core/plugins-workspace/claude-desktop/src/verify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
22 changes: 22 additions & 0 deletions hypaware-core/smoke/flows/gateway_codex_capture.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
5 changes: 4 additions & 1 deletion llp/0086-attach-tracks-ephemeral-port.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
21 changes: 12 additions & 9 deletions llp/0141-codex-desktop-rides-the-codex-adapter.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading