Skip to content

Port Registrar Foundation#2332

Open
TalZaccai wants to merge 7 commits into
mainfrom
talzacc/port-registrar-foundation
Open

Port Registrar Foundation#2332
TalZaccai wants to merge 7 commits into
mainfrom
talzacc/port-registrar-foundation

Conversation

@TalZaccai
Copy link
Copy Markdown
Contributor

@TalZaccai TalZaccai commented May 13, 2026

agentServer hosts many app-agents in one process, and several of those agents open their own WebSocket / HTTP listeners for out-of-process clients (Chrome extension, VS Code extension, the Visual Studio C# plugin, the onboarding helper). Today every one of those listeners hard-codes its port. That has two practical consequences:

  • Two TypeAgent installs on the same machine collide. The second instance can't bind, and there's no good story for telling its clients where to look.
  • Changing a port is a multi-repo edit. The agent and every external client have to move in lockstep.

We want to flip the model: agents bind port = 0, the OS picks a free port, the agent registers that port with a central registrar, and external clients discover the port over a well-known channel on agentServer.

This PR is the foundation only — it builds the registrar, plumbs it through the SDK, stands up the discovery channel, and ships the matching client-side discovery helper. Per-agent migrations follow in PRs 2–5.

What this PR introduces

A shared PortRegistrar

The registrar is owned by the dispatcher package rather than agentServer itself, so any future host of the dispatcher (the desktop shell, a hypothetical web API) gets the same model for free. agentServer is just the first host that wires one up.

The registrar tracks (agentName, role) → port allocations, scoped by an opaque sessionContextId so we can clean up everything an agent registered when its session ends. Re-registering the same (agent, role) replaces the prior entry rather than stacking duplicates — this matches what an agent actually means when it re-binds (e.g. after a config change).

A new SDK surface for agents

SessionContext gains registerPort(port, role?) / releasePort(id) and exposes a stable sessionContextId. The same surface is mirrored in agent-rpc so out-of-process agents see exactly what in-process agents see.

AppAgentManager mints a sessionContextId once per initializeSessionContext, clears it on closeSessionContext, and a finally backstop calls releaseAllForSession so a partially-failed init can't leak allocations.

A discovery channel on agentServer

A new WS-RPC channel named "discovery" is multiplexed onto the same WebSocket as the existing agent-server channel. It exposes one read-only call:

lookupPort(agentName, role?) → { port: number | null }

That's the only operation. Mutation (registering ports) requires being the agent itself, in-process, via SessionContext.registerPort — there is no remote register/release on the wire. null means "no such allocation"; callers don't need try/catch around every probe.

agentServer also calls setAgentServerPort() once it's listening, so the host itself is discoverable under the agent name agent-server — useful for clients that bootstrap from a different known port.

A client-side discoverPort() helper

The symmetric counterpart to the discovery channel, exported via a narrow ./discovery subpath on @typeagent/agent-server-client:

discoverPort(agentName, role?, { url?, timeoutMs? })
   { kind: "found", port } | { kind: "not-registered" } | { kind: "unreachable", error }

The tagged result is intentional — it lets callers distinguish "agent isn't loaded yet, retry" from "agentServer isn't running, fall back to a hardcoded default for back-compat" without parsing error strings.

The reason it's a separate subpath rather than living on the top-level agent-server-client entry: extensions and service workers can't ship fs / os / child_process / dispatcher RPC. The ./discovery subpath imports only agent-rpc, isomorphic-ws, and the small agent-server-protocol constants module. PRs 2–5 all reuse this same import.

Idle-shutdown safety

agentServer's idle-shutdown timer now bails out when registrar.hasActiveAllocations() is true, so cached extension clients can reconnect to a still-running server. The backstop in closeSessionContext ensures the registrar drains naturally on session close, so this guard never wedges shutdown indefinitely.

Origin-allowlist hook on webSocketChannelServer

webSocketChannelServer accepts an optional originAllowlist (case-insensitive, supports a trailing * for prefix-match like chrome-extension://*; native CLI clients with no Origin header are always allowed). The hook is in place so future hardening doesn't require another protocol change.

Constants instead of 8999 everywhere

AGENT_SERVER_DEFAULT_PORT = 8999 and AGENT_SERVER_DEFAULT_URL now live in agent-server-protocol. All the places that previously hard-coded 8999 (CLI commands, the shell, vscode-shell, the Visual Studio webview, the browser extension service worker, uriHandler, commandExecutor, agentServer/{stop,status}) now import the constant. Behaviorally identical; just removes a class of "I changed it in one place but not the other" bugs.

A wire-format change worth calling out

ContextParams on agent-rpc gains a required sessionContextId field. This is safe because rpc client and server always build and ship together within this monorepo, but it's worth flagging for anyone watching for compatibility-affecting changes.

Validation

  • Full pnpm run build from ts/ — green.
  • pnpm run jest-esm --testPathPattern="portRegistrar|sessionContext"27/27 passing (registrar + session lifecycle).
  • pnpm --filter @typeagent/agent-server-client test4/4 passing for discoverPort. These spin up a real ws server speaking the agent-rpc channel framing and exercise found, not-registered, unreachable (server bound and immediately closed), and timeout (server accepts but never resolves) — the missing wire-protocol coverage for the discovery channel.
  • Manual smoke: agentServer starts on the default port, idle-shutdown defers while an allocation is held, and releases the moment closeSessionContext runs.

Reading order for reviewers

The PR is four commits, each independently buildable and reviewable:

  1. feat(dispatcher): add PortRegistrar class with unit tests — pure data structure + tests. Read this first to internalize the model; nothing else depends on the rest of the codebase.
  2. refactor(dispatcher): integrate PortRegistrar into AppAgentManager + SDK — wires the registrar into the dispatcher's session lifecycle and exposes the agent-facing API. The interesting bits are the sessionContextId lifecycle in appAgentManager.ts and the matching surface on agent-rpc.
  3. agentServer: add discovery channel + consolidate AGENT_SERVER_PORT — the host wiring. The discovery channel handler in server.ts is small (one RPC method); most of the diff size is the mechanical 8999 → constant replacement across CLI/shell/extensions.
  4. agent-server-client: add discoverPort helper + wire-protocol tests — symmetric client of commit 3. Small standalone helper + 4 integration tests against a real ws server speaking the actual framing. Bootstraps Jest in the package using the standard repo convention.

Follow-up PRs

PR Scope
2 Migrate code agent (and trivially localView) — first consumer of discoverPort
3 Migrate browser agent service worker
4 Migrate visualStudio host webview
5 Migrate onboarding-scaffolder + tighten originAllowlist on agentServer

TalZaccai and others added 3 commits May 12, 2026 15:38
Foundation piece for the port-registrar PR. Standalone in-memory
registry keyed by (agentName, role, sessionContextId). API surface:
register/release/releaseAllForSession/lookup/hasActiveAllocations.

Rejects port 0 and out-of-range; warns (does not throw) on
privileged ports or the agentServer's own port. Idempotent on the
(agentName, role, sessionContextId) triple. Most-recent-wins lookup
semantics match the legacy setLocalHostPort behavior the registrar
will subsume in the next commit.

Not wired into anything yet — pure class + 19 unit tests, all
passing. Integration with appAgentManager / sessionContext /
agent-server discovery channel follows in subsequent commits.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Wire the PortRegistrar (added in 670222d) into the dispatcher's app-agent

lifecycle and SDK surface so agents can register OS-assigned ports through

a single source of truth.

- AppAgentManager now takes a PortRegistrar in its constructor; the legacy

  per-record 'port' field is replaced by a 'sessionContextId' UUID minted

  fresh on each initializeSessionContext and cleared in closeSessionContext.

  setLocalHostPort/getLocalHostPort/getSharedLocalHostPort are now thin

  shims over the registrar (DEFAULT_ROLE='default'); permission checks

  and the appAgent-undefined guard in getSharedLocalHostPort are preserved.

- closeSessionContext gains a finally backstop that calls

  releaseAllForSession(sessionContextId) so a forgetful or crashing agent

  can never leak a registration past its lifetime, even if init itself

  rejected.

- SessionContext SDK gets readonly sessionContextId + registerPort(role,port)

  returning {release()}. The legacy setLocalHostPort/getSharedLocalHostPort

  are kept and marked @deprecated so existing agents keep working unchanged.

- agent-rpc proxies the new registerPort/releasePort and threads

  sessionContextId through ContextParams so out-of-process agents see the

  same view as in-process ones. Registration handles are tracked by regId

  on the dispatcher side.

- DispatcherOptions accepts an optional shared PortRegistrar so a host

  (agentServer) can wire one instance across all conversations; standalone

  hosts get a process-private one by default.

- Folds in the rubber-duck #3 fix to PortRegistrar.register: re-registering

  an existing (agent,role,session) triple now deletes+reinserts the entry

  so Map insertion order reflects recency for lookup tie-breaking, plus a

  regression test covering the ordering invariant.

Mocks updated in dispatcher/test/sessionContext.spec.ts and

browser/websiteMemory.mts. Full monorepo build succeeds; 27/27 tests pass

(20 portRegistrar + 7 sessionContext).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sub-PR C of port-registrar-foundation. Wires PortRegistrar (sub-PR B) into the agentServer host and exposes a read-only lookup API to external clients.

Protocol (agent-server-protocol):

- AGENT_SERVER_DEFAULT_PORT (8999), AGENT_SERVER_DEFAULT_URL constants

- DiscoveryChannelName = 'discovery', DiscoveryInvokeFunctions { lookupPort }

agentServer:

- Constructs a process-wide PortRegistrar and threads it through baseOptions.portRegistrar to every conversation's dispatcher

- Mounts the discovery channel alongside the agent-server channel on each WS connection (multiplexed on the same socket); lookupPort(agent, role) returns {port|null}

- scheduleIdleShutdown() now bails when registrar.hasActiveAllocations() so cached extension clients can reconnect

- Calls registrar.setAgentServerPort(port) once the WS server is listening so 'agent-server' itself is discoverable

webSocketChannelServer:

- New WebSocketChannelServerOptions.originAllowlist (case-insensitive, supports '*' suffix prefix-match; no-Origin always allowed for native clients). Permissive default for v1; agentServer opts in later.

Port literal consolidation (8999 -> AGENT_SERVER_DEFAULT_PORT/_URL):

- agent-server: stop.ts, status.ts

- agent-server-client: agentServerClient.ts default args

- CLI: 12 command files

- vscode-shell, visualStudio webview (dispatcherConnection + main banner), browser extension service worker, uriHandler, commandExecutor, shell args

Dispatcher exports PortRegistrar/PortAllocation/PortRegistrationId so hosts (agentServer today, future shell/web) can inject one.

Build green; 27/27 portRegistrar+sessionContext tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces the foundational infrastructure for dynamic, discoverable per-agent ports by adding an in-memory PortRegistrar to the dispatcher, exposing a port-registration API via SessionContext (including agent-rpc parity), and hosting a new read-only discovery WS-RPC channel on agentServer. The PR also centralizes the agent-server default port/URL constants to eliminate scattered 8999 literals and adds an Origin allowlist hook to the WebSocket channel server for future hardening.

Changes:

  • Add PortRegistrar (with unit tests) and wire it through dispatcher initialization + AppAgentManager session lifecycle.
  • Add port registration surface to SessionContext (and agent-rpc) and introduce a new discovery WS-RPC channel (lookupPort) on agent-server.
  • Consolidate default agent-server port/URL constants and replace hard-coded 8999 usages across CLI/shell/extensions.

Reviewed changes

Copilot reviewed 39 out of 39 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
ts/packages/vscode-shell/src/agentServerBridge.ts Use shared default agent-server URL constant instead of hard-coded 8999.
ts/packages/utils/webSocketChannelServer/src/server.ts Add optional originAllowlist support via ws verifyClient during upgrade.
ts/packages/uriHandler/src/index.ts Default CLI port now uses AGENT_SERVER_DEFAULT_PORT.
ts/packages/shell/src/main/args.ts Shell default connect port now uses AGENT_SERVER_DEFAULT_PORT.
ts/packages/dispatcher/dispatcher/test/sessionContext.spec.ts Update tests for new createSessionContext(..., sessionContextId) signature and mock portRegistrar.
ts/packages/dispatcher/dispatcher/test/portRegistrar.spec.ts New unit tests for PortRegistrar behavior (register/lookup/release semantics).
ts/packages/dispatcher/dispatcher/src/index.ts Export PortRegistrar and related types from dispatcher package.
ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts Add sessionContextId and implement SessionContext.registerPort().
ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts New in-memory registrar implementation with session-scoped allocations.
ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts Add registrar to context and dispatcher options; construct/share registrar.
ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts Generate/manage sessionContextId, route legacy port APIs through registrar, and ensure backstop cleanup on failure/close.
ts/packages/commandExecutor/src/commandServer.ts Default agent-server URL now uses AGENT_SERVER_DEFAULT_URL.
ts/packages/cli/src/slashCommands.ts Use AGENT_SERVER_DEFAULT_PORT for shutdown default.
ts/packages/cli/src/commands/server/stop.ts Use AGENT_SERVER_DEFAULT_PORT as default flag value.
ts/packages/cli/src/commands/server/status.ts Use AGENT_SERVER_DEFAULT_PORT as default flag value.
ts/packages/cli/src/commands/run/translate.ts Use AGENT_SERVER_DEFAULT_PORT as default flag value.
ts/packages/cli/src/commands/run/request.ts Use AGENT_SERVER_DEFAULT_PORT as default flag value.
ts/packages/cli/src/commands/run/explain.ts Use AGENT_SERVER_DEFAULT_PORT as default flag value.
ts/packages/cli/src/commands/replay.ts Use AGENT_SERVER_DEFAULT_PORT as default flag value.
ts/packages/cli/src/commands/conversations/rename.ts Use AGENT_SERVER_DEFAULT_PORT as default flag value.
ts/packages/cli/src/commands/conversations/list.ts Use AGENT_SERVER_DEFAULT_PORT as default flag value.
ts/packages/cli/src/commands/conversations/delete.ts Use AGENT_SERVER_DEFAULT_PORT as default flag value.
ts/packages/cli/src/commands/conversations/create.ts Use AGENT_SERVER_DEFAULT_PORT as default flag value.
ts/packages/cli/src/commands/connect.ts Use AGENT_SERVER_DEFAULT_PORT as default flag value.
ts/packages/agentServer/server/src/stop.ts Use AGENT_SERVER_DEFAULT_PORT when --port absent.
ts/packages/agentServer/server/src/status.ts Use AGENT_SERVER_DEFAULT_PORT when --port absent.
ts/packages/agentServer/server/src/server.ts Create shared registrar for all conversations; add discovery channel; idle shutdown now considers active allocations.
ts/packages/agentServer/protocol/src/protocol.ts Define discovery channel name + RPC type, and add default port/URL constants.
ts/packages/agentServer/protocol/src/index.ts Re-export discovery + default port/URL constants.
ts/packages/agentServer/client/src/index.ts Re-export AGENT_SERVER_DEFAULT_PORT/URL to clients.
ts/packages/agentServer/client/src/agentServerClient.ts Default port parameters now use AGENT_SERVER_DEFAULT_PORT.
ts/packages/agentSdk/src/agentInterface.ts Add sessionContextId + new registerPort() API; deprecate legacy port methods.
ts/packages/agents/visualStudio/host/webview/src/main.ts Display default agent-server URL via shared constant.
ts/packages/agents/visualStudio/host/webview/src/dispatcherConnection.ts Replace hard-coded default URL with shared constant.
ts/packages/agents/browser/src/extension/serviceWorker/dispatcherConnection.ts Replace hard-coded default URL with shared constant.
ts/packages/agents/browser/src/agent/websiteMemory.mts Update SessionContext mock to include registerPort + sessionContextId.
ts/packages/agentRpc/src/types.ts Add registerPort/releasePort RPC methods and require sessionContextId in ContextParams.
ts/packages/agentRpc/src/server.ts Plumb sessionContextId through SessionContext shim; add registerPort implementation for out-of-process agents.
ts/packages/agentRpc/src/client.ts Implement dispatcher-side handlers for registerPort/releasePort and include sessionContextId in context params.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ts/packages/agentServer/server/src/server.ts
Comment thread ts/packages/agentServer/protocol/src/protocol.ts Outdated
Comment thread ts/packages/agentRpc/src/client.ts
Comment thread ts/packages/agentSdk/src/agentInterface.ts
TalZaccai and others added 2 commits May 12, 2026 19:17
Conflicts in appAgentManager.ts (init/close paths) and vscode-shell agentServerBridge.ts resolved by combining the readiness/setup framework from main with this branch's portRegistrar wiring.

Test fixups required by main + this branch:

- agentReadiness.spec.ts: AppAgentManager constructor now takes a PortRegistrar — pass new PortRegistrar()

- sessionContext.spec.ts (4 call sites): createSessionContext signature gained sessionContextId — pass a literal id

Build green, 57/57 portRegistrar+sessionContext+agentReadiness tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1. Discovery: special-case 'agent-server' agent name to return the live registered server port via portRegistrar.getAgentServerPort(), so external clients can discover the configured port even when it differs from the bootstrap port. New AGENT_SERVER_DISCOVERY_NAME constant in protocol.

2. Discovery: make 'role' optional on DiscoveryInvokeFunctions.lookupPort and on PortRegistrar.lookup (defaults to DEFAULT_ROLE), matching the documented intent and aligning with what setLocalHostPort registers.

3. agent-rpc client: track regIds per contextId in regIdsByContext and release any unreleased handles in the closeAgentContext wrapper. Prevents handle leaks when an out-of-process agent crashes or forgets to release. releasePort RPC param gains optional contextId so explicit releases also clean up the per-context index.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@TalZaccai TalZaccai temporarily deployed to development-fork May 13, 2026 02:42 — with GitHub Actions Inactive
@TalZaccai TalZaccai temporarily deployed to development-fork May 13, 2026 02:42 — with GitHub Actions Inactive
@TalZaccai TalZaccai marked this pull request as ready for review May 13, 2026 03:58
@TalZaccai TalZaccai requested a review from robgruen May 13, 2026 03:58
Symmetric client-side counterpart to the discovery channel introduced
in commit 3 of this series. Without it, PR 1 ships a server-side
WS-RPC handler with no callers and no end-to-end test of the actual
wire format -- only an in-process unit test of the registrar.

* New `discoverPort(agentName, role?, options?)` exported via a
  narrow `./discovery` subpath on `@typeagent/agent-server-client`.
  Returns a tagged result -- `found` / `not-registered` /
  `unreachable` -- so callers can distinguish "agent isn't loaded
  yet, retry" from "agentServer isn't running, fall back to a
  hardcoded default for back-compat" without parsing error strings.
* Subpath rather than top-level export so external clients (browser
  extension, VS Code extension service workers) don't drag in the
  full main-client surface (fs / os / child_process / dispatcher RPC).
  The discovery module imports only `agent-rpc`, `isomorphic-ws`,
  and the small `agent-server-protocol` constants module.
* 4 integration tests spin up a real `ws` server speaking the
  `agent-rpc` channel framing (createChannelProviderAdapter +
  createRpc) and exercise: `found`, `not-registered`,
  `unreachable` (server bound and immediately closed),
  and timeout (server accepts but never resolves). Bootstraps
  Jest in the package along the way -- one `jest.config.cjs`
  delegating to the shared root config, plus a `test/` tsconfig
  matching the convention used by every other test-bearing
  package in the repo.

This unblocks the per-agent migration PRs (2--5): each will import
`discoverPort` from the same subpath rather than re-implementing
the discovery handshake.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
};

/** What `discoverPort` returns. */
export type DiscoverPortResult =
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want the notion of a transient port (i.e. one that can come and go i.e. not guaranteed to be up)?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't the place for that, just asking here since it just came to me.

*/
export async function discoverPort(
agentName: string,
role?: string,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be an enum?

// which conversation that agent is loaded into. Standalone hosts
// (shell, CLI dispatcher) skip this and let each dispatcher mint
// its own — see DispatcherOptions.portRegistrar in agent-dispatcher.
const portRegistrar = new PortRegistrar();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this then be a singleton if we ever just want one of them?

description: "Shut down the agent server",
handler: async () => {
const port = serverPort ?? 8999;
const port = serverPort ?? AGENT_SERVER_DEFAULT_PORT;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dang, this was ALL OVER THE PLACE!

* Thread-safety: Node single-threaded; the registrar is mutated only on
* the event-loop thread. No locking required.
*/
export class PortRegistrar {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this thing implement an interface such that others can provide their own port registration mechanism? Couldn't hurt to define the interface and export it on the off chance.

Comment thread ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts
);
}

const tripleKey = this.makeTripleKey(agentName, role, sessionContextId);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of a triple key, couldn't this be an object that itself is the key? Would be easier to extend if we add a fourth property.

* Remove a single allocation by its registration id. Idempotent: a
* release of an unknown id is a no-op.
*/
public release(regId: RegistrationId): void {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably need a way to ensure that agents only release their own ports.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants