Skip to content

fix(init): stop pinning the gateway listen port so LLP 0114's default applies - #433

Merged
philcunliffe merged 1 commit into
masterfrom
fix/issue-431
Jul 28, 2026
Merged

fix(init): stop pinning the gateway listen port so LLP 0114's default applies#433
philcunliffe merged 1 commit into
masterfrom
fix/issue-431

Conversation

@philcunliffe

Copy link
Copy Markdown
Contributor

What was wrong

Both hyp init config writers hardcoded an explicit listen: '127.0.0.1:8787' into the @hypaware/ai-gateway slice:

  • src/core/cli/walkthrough.js (composePickerConfig, the interactive / --yes path)
  • hypaware-core/plugins-workspace/claude/src/index.js (the claude-and-otel-local preset)

Both predate LLP 0114. The wrong port is the visible half; the serious half is that an explicit listen is a stated port requirement, so compileConfig sets listenConfigured: true and the gateway source takes the loud-failure branch (LLP 0114 §explicit-listen-fails-loudly) instead of the default-only ephemeral fallback. Every wizard-created install was opted out of the "the daemon always starts" invariant without the user ever naming a port, which is what feeds #137.

The fix

Subtractive: neither writer emits listen. DEFAULT_LISTEN (127.0.0.1:18521) applies and listenConfigured === false re-arms the fallback.

One knock-on needed handling. The walkthrough attaches clients before the finale restarts the daemon, so the gateway is usually not bound in-process and configuredGatewayEndpoint() now returns undefined. The pre-existing http://127.0.0.1:0 placeholder is not a usable address, and attach failed with cannot derive port from endpoint 'http://127.0.0.1:0' (caught by the picker smoke). The walkthrough now falls back to the fixed default through a new core constant DEFAULT_GATEWAY_ENDPOINT in src/core/config/gateway_endpoint.js, pinned against the plugin's DEFAULT_LISTEN by a test so the two cannot drift. Manual hyp attach is deliberately untouched: it still discovers the proven port from status.json rather than guessing (LLP 0086 §manual-attach-reads-the-live-port).

LLP 0114 gains a #init-writes-no-listen consequence recording both, and the code carries @refs to it.

Regression test

New test/core/init-gateway-listen-default.test.js drives the real writers (composePickerConfig over the real bundled manifests, and the real claude-and-otel-local preset via the plugin's activate()), asserts the gateway slice has no listen key, and round-trips the emitted slice through compileConfig to prove it yields 127.0.0.1:18521 with listenConfigured === false.

Before the fix: both writer tests fail with must not pin a gateway listen address / true !== false. After: 3/3 pass.

Assertions updated (they encoded the old behaviour)

  • test/core/compose-picker-config.test.js - 11 golden picker configs dropped the pinned listen.
  • hypaware-core/smoke/flows/walkthrough_picker_to_first_query.js - goldenPickerConfig dropped listen; the post-init attach assertions now expect the default 18521 / http://127.0.0.1:18521 instead of the wizard-pinned 8787.
  • hypaware-core/smoke/flows/walkthrough_to_first_query.js - goldenConfig dropped listen; stale "standard defaults (8787, 4318)" comment corrected.
  • llp/0010-config-model.spec.md - the illustrative config example no longer shows a pinned listen.

Deliberately not changed: config_load_validate.js:50 and status_diagnostics.js still write an explicit listen: 127.0.0.1:8787 in their hand-authored fixtures. Those are not writer output; they exercise the still-supported explicit-listen path, and an explicit port there is the point. Same for the explicit-listen fixtures in join-command, attach-endpoint-fallback, config-merge, and daemon-attach-seam tests.

Checks

  • npm test: 2777 pass / 8 fail. The 8 are test/core/leave-command.test.js, failing identically on unmodified origin/master in this container (no systemctl on PATH).
  • npm run typecheck: clean.
  • Smokes: walkthrough_picker_to_first_query OK (fails before this change), core_boot_noop, status_diagnostics, client_attach_idempotent, gateway_claude_capture, daemon_foreground_start_stop, walkthrough_backfill_client_history, ai_gateway_passthrough all OK. walkthrough_to_first_query, config_load_validate, claude_attach_detach, client_attach_on_join fail, but each fails identically on unmodified origin/master here (verified by stashing) at an assertion unrelated to the listen port.

Fixes#431

… applies
`hyp init` wrote an explicit `listen: '127.0.0.1:8787'` into the
`@hypaware/ai-gateway` slice from both config writers: the picker
walkthrough (`composePickerConfig`) and the `claude-and-otel-local`
preset. Both predate LLP 0114, so a fresh install landed on the wrong
port and, worse, opted itself out of the EADDRINUSE safety net: an
explicit `listen` is a stated port requirement, so `compileConfig` marks
it `listenConfigured` and the source fails loudly instead of falling
back to `127.0.0.1:0` (LLP 0114 #explicit-listen-fails-loudly). Every
wizard-created install therefore reached the loud-failure branch it
never asked for, feeding #137.
Subtractive fix: neither writer emits `listen`, so `DEFAULT_LISTEN`
(`127.0.0.1:18521`) applies and `listenConfigured === false` re-arms the
default-only fallback.
One consequence needed handling: the walkthrough attaches clients before
the finale restarts the daemon, so the gateway is usually not bound in
this process and `configuredGatewayEndpoint()` now returns undefined.
The old `http://127.0.0.1:0` placeholder is not a usable address (attach
failed with "cannot derive port from endpoint"), so the walkthrough now
falls back to the fixed default via a new core
`DEFAULT_GATEWAY_ENDPOINT`, pinned against the plugin's `DEFAULT_LISTEN`
by test. Manual `hyp attach` is untouched: it still reads the proven
port from status.json rather than guessing (LLP 0086).
LLP 0114 gains a #init-writes-no-listen consequence recording both.
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Verdict: clean (no actionable findings)

Reviewed f55f41acbc61d63791df713eb14b803d2a692472 in a detached worktree off origin/fix/issue-431, with origin/master (6a64273) checked out alongside as a baseline. The fix is correct, minimally scoped, and the one knock-on change is genuinely forced rather than opportunistic. I re-derived each of the author's judgement calls instead of taking them on faith; details and evidence below.

1. The subtractive fix itself

src/core/cli/walkthrough.js:659-663 and hypaware-core/plugins-workspace/claude/src/index.js:348-355 are the complete set of listen writers. I verified that independently rather than trusting the issue:

  • Repo-wide grep 8787 leaves zero hits under src/ and zero in any writer. Every survivor is a hand-authored fixture, an explicit-listen test, or the SKILL.md shell fallbacks that hyp init writes an explicit listen: 127.0.0.1:8787, forfeiting LLP 0114's fixed default and its EADDRINUSE fallback #431 declared out of scope.
  • The only other registered init preset is gascity (hypaware-core/plugins-workspace/gascity/src/index.js:67), and it writes no @hypaware/ai-gateway slice at all, so there is no third writer to fix.
  • listen is not required anywhere: the ai-gateway manifest declares no config schema and compileConfig (hypaware-core/plugins-workspace/ai-gateway/src/config.js:26-33) defaults it, so omitting it is validation-safe. hyp config validate inside the real-init step of walkthrough_picker_to_first_query passes with empty stderr.
  • prepareLocalConfigWrite (src/core/config/schema.js:64-91) is an overwrite-with-backup, not a merge, so the composed slice is exactly what lands on disk. There is no path where init leaves a stale pinned listen behind while the walkthrough attaches at the default.

2. Scope: was DEFAULT_GATEWAY_ENDPOINT forced? Yes.

Confirmed the failure the author describes is real and not hand-waved. With no listen, configuredGatewayEndpoint() returns undefined (src/core/config/gateway_endpoint.js:35-42), and the old 'http://127.0.0.1:0' placeholder reaches the non-dry-run attach branch, which uses endpointPort(), not safeEndpointPort():

  • hypaware-core/plugins-workspace/claude/src/index.js:187 -> :415-422 throws cannot derive port from endpoint '...' for port 0.
  • Same shape at hypaware-core/plugins-workspace/codex/src/index.js:329.

So keeping the placeholder would have broken real hyp init attach outright. The alternatives (reorder the finale to restart before attaching, or skip attach when nothing is derivable) are both larger and worse. The chosen fallback is the minimal correct one.

Correct under LLP 0114/0086, including the fallback case. I checked the specific worry that 18521 is a guess when the daemon actually binds an ephemeral port. It is a guess, and it self-corrects:

  • resolveClientActionSeam (src/core/daemon/runtime.js:833-845) resolves the reconcile seam's endpoint from the proven-bound clients.localEndpoint(), and threads it into every pass at src/core/daemon/runtime.js:379. So the daemon boot that runPickerFinale itself triggers re-attaches at whatever actually got bound.
  • Crucially, the new behavior is strictly better than the old one in exactly this scenario. Pre-fix, a pinned-and-taken 8787 was listenConfigured: true, so the gateway took the loud-failure branch, never bound, ctx.endpoint stayed undefined, and the client was stranded at the squatter permanently (LLP 0086 D1's "no live endpoint this pass" guard leaves the marker alone). Post-fix the gateway falls back, binds, and the drift re-attach fires. The exposure window shrinks from unbounded to one boot.
  • The residual window is the one LLP 0114 §interception-accepted already accepts by name and declines to harden.

3. The duplicated constant

src/core/config/gateway_endpoint.js:16. This matches an existing precedent, not a new one: hypaware-core/plugins-workspace/claude-desktop/src/profile.js:18 already carries STABLE_DEFAULT_LISTEN = '127.0.0.1:18521' for the same plugin-boundary reason, pinned by test/plugins/claude-desktop-profile.test.js:37.

I verified the new pin actually bites rather than merely existing. Editing DEFAULT_LISTEN to 127.0.0.1:19999 in hypaware-core/plugins-workspace/ai-gateway/src/config.js turns the pin red:

not ok 1 - core's default gateway endpoint tracks the ai-gateway plugin's DEFAULT_LISTEN
expected: 'http://127.0.0.1:19999'
actual: 'http://127.0.0.1:18521'
# pass 0 # fail 3

4. Updated vs deliberately-not-updated assertions

The distinction holds. I checked both untouched fixtures by hand:

  • hypaware-core/smoke/flows/config_load_validate.js:50 is a config the flow writes to disk itself to exercise load/validate. Not writer output. Leaving the explicit listen is correct, and the smoke's failure mode is unchanged (see checks below).
  • hypaware-core/smoke/flows/status_diagnostics.js:391 (healthyConfig(), a local fixture builder) pins 8787 and :124-125 pre-writes a Claude marker at 8787 to match. That pairing is load-bearing: the flow needs the configured endpoint and the recorded marker port to agree so the healthy case emits no client_attach_stale. Dropping the listen there would have changed the diagnostic under test, not removed a bug. status_diagnostics passes on this branch.
  • The updated ones are all genuine writer output or golden copies of it: test/core/compose-picker-config.test.js (11 goldens), goldenPickerConfig/goldenConfig in the two walkthrough smokes. The walkthrough_to_first_query golden assertion sits at line 167, ahead of that flow's pre-existing failure at line 317, so it is genuinely exercised and passing.

No assertion was loosened. Every edit removed a key; none relaxed a comparison.

5. The regression test is real

test/core/init-gateway-listen-default.test.js drives the real composePickerConfig over the real bundled manifests via discoverBundledPlugins()/buildPluginCatalog(), and the real claude-and-otel-local preset through the plugin's own activate() + preset.run(), reading the config back off disk. No reimplementation.

I reintroduced the bug in a scratch copy (re-added listen: '127.0.0.1:8787' to both writers) and confirmed it goes red on both writers:

ok 1 - core's default gateway endpoint tracks the ai-gateway plugin's DEFAULT_LISTEN
not ok 2 - the picker writer leaves the gateway listen unset so the fixed default applies
picker(claude) must not pin a gateway listen address
expected: false actual: true
not ok 3 - the claude-and-otel-local preset leaves the gateway listen unset so the fixed default applies
the preset must not pin a gateway listen address
expected: false actual: true
# pass 1 # fail 2

Both writers are covered, and the compileConfig round-trip pins listenConfigured === false, which is the half of the bug that actually matters.

6. The disclosed manual-attach risk: acceptable as-is, not blocking

A default install now has no configured listen, so hyp attach with the daemon down and no status.json reaches the actionable "cannot resolve the gateway endpoint" error instead of writing the wizard-pinned 8787.

That is the documented behavior, not a regression introduced here. LLP 0086 D2 spells it out: "When no live endpoint is discoverable (daemon not running) the pre-#271/#277 behavior is preserved: a present marker is a no-op success, an absent one the actionable ... error." src/core/commands/clients.js:207-232 implements exactly that, and test/core/attach-endpoint-fallback.test.js already tests this precise shape (a live-but-unbound gateway with no configured listen) and passes on this branch. The old 8787 behavior was the worse one: it wrote a base URL for a port nothing had bound, which is the failure mode LLP 0045 §Part 1 exists to prevent. This PR moves the default install onto the tested path rather than off it.

7. Conventions

  • Zero em dashes (U+2014) on any added line, verified with git diff -U0 | grep '^+'. (The two in src/core/config/gateway_endpoint.js:24,29 are pre-existing and untouched; there are 187 such lines across 23 files in src/, so it is standing debt, not this PR's.)
  • No semicolons added. JSDoc types only, no inline import('...'), root-anchored .js type-import specifiers in the new test file.
  • All four @ref anchors resolve: LLP 0114#init-writes-no-listen (added by this PR), LLP 0114#fixed-default-port, LLP 0114#explicit-listen-fails-loudly (prose), LLP 0086#manual-attach-reads-the-live-port.
  • The LLP edit lands in the same commit as the code, as CLAUDE.md requires.

Checks I ran

CI on f55f41a: 8/8 SUCCESS (typecheck (22), typecheck (24), test (22), test (24), two workflow runs).

Locally, in a worktree with node_modules symlinked from the main checkout:

  • npm run typecheck: clean.

  • npm test: 2777 pass / 8 fail. Baseline confirmed by running origin/master in a second worktree: 2774 pass / the same 8test/core/leave-command.test.js failures (no systemctl on PATH). The delta is exactly the 3 new tests.

  • Smokes, each run on both this branch and origin/master:

    • walkthrough_picker_to_first_query: ok on both.
    • status_diagnostics: ok. client_attach_idempotent: ok.
    • walkthrough_to_first_query: fails on both at sql: ai_gateway_messages has exactly one row for this dev_run_id (value=0), after the golden-config assertion passes.
    • config_load_validate: fails on both at case 2: stderr includes the sink_pair_incompatible error_kind tag.
    • claude_attach_detach: fails on both at settings: SessionStart hook installed with --state-file pointing at the plugin state dir.
    • client_attach_on_join: fails on both at no re-attach: the attach.claude marker timestamp is unchanged (done short-circuits).

    Same assertion, same value, both sides. Every smoke failure is pre-existing and unrelated to the listen port.

Non-blocking nits (no change requested)

  • src/core/cli/walkthrough.js:844 refs LLP 0114#fixed-default-port, but the section that literally describes this line ("the walkthrough's pre-restart attach wires clients at the fixed default") is #init-writes-no-listen, added by this same PR. The current ref is honest and resolves; #init-writes-no-listen would just be more precise.
  • src/core/config/gateway_endpoint.js:28-30: the JSDoc still says callers "fall back to a placeholder or surface the gap". Still true of src/core/commands/clients.js:201 (dry-run), now stale for the walkthrough, which falls back to a real address.
  • src/core/commands/clients.js:201 keeps the 'http://127.0.0.1:0' dry-run placeholder. Harmless (safeEndpointPort at claude/src/index.js:433 tolerates it and the human prose never printed a port), but on a post-fix default install hyp attach --dry-run --json now omits port where it used to report 8787. A one-line consistency follow-up if anyone cares.

What I could not verify

I did not run an end-to-end ephemeral-fallback rehearsal (occupy 18521, run real init, restart the daemon, observe the marker move off 18521). I verified its two halves separately instead: the fallback bind and its warning in test/plugins/ai-gateway-listen-default.test.js:64-67, and the proven-bound seam in src/core/daemon/runtime.js:833-845. The composition of the two through the walkthrough is reasoned, not executed.

Nothing pushed; the head SHA is unchanged.

@philcunliffe
philcunliffe marked this pull request as ready for review July 28, 2026 22:57
@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Jul 28, 2026
@philcunliffe
philcunliffe merged commit 8eccc04 into masterJul 28, 2026
8 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-431 branch July 28, 2026 22:58
philcunliffe pushed a commit that referenced this pull request Jul 29, 2026
Two resolutions, one textual and one semantic. Neither reverses a landed
decision.
1. `claude-desktop/hypaware.plugin.json` (the only git-reported conflict).
#445 (`74aea66`) deleted the `attach_probe` block from
`contributes.client`, restoring the Accepted decision at
LLP 0115#no-attach-on-join and LLP 0135#no-probe. This branch added
`transcript_entrypoints` as a sibling of that block, so the two edits
collided on the same closing brace and nothing more. Took master's
deletion whole and kept only the new field:
"agent_dir": ".claude/agents",
"transcript_entrypoints": ["claude-desktop", "claude-desktop-3p"]
The probe stays gone. `transcript_entrypoints` is a backfill-ownership
label read by `resolveOwnersForRun` (LLP 0140), never by
`probeClientAttachFromDescriptor` or `detachClientFromDisk`, so it does
not reintroduce an attach-on-join path and `test/plugins/
claude-desktop-detach.test.js`'s no-probe assertion still holds.
2. `test/core/compose-picker-config.test.js` (a silent semantic conflict
git auto-merged). #433 (`8eccc04`) stopped `composePickerConfig` pinning
the gateway `listen` so LLP 0114's fixed default applies to a
wizard-created install. The `claude-desktop composes ...` case added
here predates that and still expected
`listen: '127.0.0.1:8787'`, so it was the one gateway slice in the file
carrying a `listen` and it failed on the merge. Dropped the pin to match
master's landed decision and the other eleven cases; the assertion this
test exists for, that the Desktop row composes both plugins its
`configure_command` needs, is untouched.
`src/core/daemon/status.js` merged clean and reads coherently: master's
no-probe guard (`descriptor.attachProbe ? probe : { attached: false }`)
sits directly above this branch's `client_attach_missing` repair fix. Post
#445 that fix matters more, not less: with no probe at all, a configured
claude-desktop always reports `attached: false`, so the repair line is
always printed, and the generic `hyp attach --client claude-desktop` it
used to print answers `unknown client`.
npm test: 2873 pass / 8 fail, exactly the pre-existing
test/core/leave-command.test.js set. npm run typecheck clean. Smokes
walkthrough_picker_to_first_query, cli_bundled_plugins_activated,
status_diagnostics ok.
Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approvedneutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

hyp init writes an explicit listen: 127.0.0.1:8787, forfeiting LLP 0114's fixed default and its EADDRINUSE fallback

1 participant

@philcunliffe