typeclaw 0.36.7 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +2 -2
  2. package/package.json +3 -2
  3. package/src/agent/index.ts +31 -11
  4. package/src/agent/live-sessions.ts +12 -0
  5. package/src/agent/model-fallback.ts +17 -15
  6. package/src/agent/model-overrides.ts +2 -2
  7. package/src/agent/session-meta.ts +10 -0
  8. package/src/agent/subagents.ts +11 -2
  9. package/src/agent/system-prompt.ts +9 -3
  10. package/src/agent/todo/continuation-policy.ts +6 -3
  11. package/src/agent/todo/continuation-wiring.ts +4 -2
  12. package/src/agent/todo/continuation.ts +3 -3
  13. package/src/agent/tools/todo/index.ts +27 -4
  14. package/src/bundled-plugins/agent-browser/index.ts +33 -108
  15. package/src/bundled-plugins/agent-browser/shim.ts +3 -94
  16. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +8 -33
  17. package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +2 -2
  18. package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +7 -1
  19. package/src/bundled-plugins/memory/README.md +80 -23
  20. package/src/bundled-plugins/memory/append-tool.ts +74 -53
  21. package/src/bundled-plugins/memory/citation-superset.ts +4 -0
  22. package/src/bundled-plugins/memory/citations.ts +54 -0
  23. package/src/bundled-plugins/memory/dreaming-metrics.ts +30 -0
  24. package/src/bundled-plugins/memory/dreaming.ts +444 -21
  25. package/src/bundled-plugins/memory/index.ts +544 -400
  26. package/src/bundled-plugins/memory/load-memory.ts +87 -10
  27. package/src/bundled-plugins/memory/load-shards.ts +48 -22
  28. package/src/bundled-plugins/memory/memory-logger.ts +95 -106
  29. package/src/bundled-plugins/memory/memory-retrieval.ts +3 -3
  30. package/src/bundled-plugins/memory/parent-link.ts +33 -0
  31. package/src/bundled-plugins/memory/paths.ts +12 -0
  32. package/src/bundled-plugins/memory/references/frontmatter.ts +197 -0
  33. package/src/bundled-plugins/memory/references/load-references.ts +212 -0
  34. package/src/bundled-plugins/memory/references/store-reference-tool.ts +59 -0
  35. package/src/bundled-plugins/memory/search-tool.ts +282 -45
  36. package/src/bundled-plugins/memory/stream-events.ts +1 -0
  37. package/src/bundled-plugins/memory/stream-io.ts +28 -3
  38. package/src/bundled-plugins/memory/turn-dedup.ts +40 -0
  39. package/src/bundled-plugins/memory/vector/cache-write.ts +19 -0
  40. package/src/bundled-plugins/memory/vector/config.ts +28 -0
  41. package/src/bundled-plugins/memory/vector/doctor.ts +124 -0
  42. package/src/bundled-plugins/memory/vector/embedder.ts +246 -0
  43. package/src/bundled-plugins/memory/vector/hybrid.ts +439 -0
  44. package/src/bundled-plugins/memory/vector/index-on-write.ts +34 -0
  45. package/src/bundled-plugins/memory/vector/inspect.ts +111 -0
  46. package/src/bundled-plugins/memory/vector/passages.ts +125 -0
  47. package/src/bundled-plugins/memory/vector/reference-index-on-write.ts +50 -0
  48. package/src/bundled-plugins/memory/vector/relevance-gate.ts +93 -0
  49. package/src/bundled-plugins/memory/vector/startup.ts +71 -0
  50. package/src/bundled-plugins/memory/vector/store.ts +203 -0
  51. package/src/bundled-plugins/memory/vector/truncation.ts +124 -0
  52. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +2 -0
  53. package/src/channels/router.ts +239 -40
  54. package/src/cli/incomplete-init.ts +57 -0
  55. package/src/cli/init.ts +143 -12
  56. package/src/cli/inspect.ts +11 -5
  57. package/src/cli/model.ts +112 -34
  58. package/src/cli/restart.ts +24 -0
  59. package/src/cli/start.ts +24 -0
  60. package/src/cli/tunnel.ts +53 -8
  61. package/src/config/config.ts +110 -19
  62. package/src/config/index.ts +5 -1
  63. package/src/config/models-mutation.ts +29 -11
  64. package/src/config/providers-mutation.ts +2 -2
  65. package/src/config/providers.ts +146 -12
  66. package/src/container/shared.ts +9 -0
  67. package/src/container/start.ts +87 -4
  68. package/src/cron/consumer.ts +13 -7
  69. package/src/hostd/models.ts +64 -0
  70. package/src/hostd/paths.ts +6 -0
  71. package/src/hostd/portbroker-manager.ts +2 -2
  72. package/src/init/checkpoint.ts +201 -0
  73. package/src/init/dockerfile.ts +164 -51
  74. package/src/init/gitignore.ts +7 -7
  75. package/src/init/index.ts +41 -9
  76. package/src/init/line-auth.ts +50 -21
  77. package/src/init/models-dev.ts +96 -21
  78. package/src/init/oauth-login.ts +3 -3
  79. package/src/init/progress.ts +29 -0
  80. package/src/init/validate-api-key.ts +4 -0
  81. package/src/inspect/index.ts +13 -6
  82. package/src/inspect/item-list.ts +11 -2
  83. package/src/inspect/live-list.ts +65 -0
  84. package/src/inspect/open-item.ts +22 -1
  85. package/src/inspect/session-list.ts +29 -0
  86. package/src/models/embedding-model.ts +114 -0
  87. package/src/models/transformers-version.ts +55 -0
  88. package/src/plugin/types.ts +3 -0
  89. package/src/portbroker/container-server.ts +23 -0
  90. package/src/portbroker/forward-request-bus.ts +35 -0
  91. package/src/portbroker/forward-result-bus.ts +2 -3
  92. package/src/portbroker/hostd-client.ts +182 -36
  93. package/src/portbroker/index.ts +6 -1
  94. package/src/portbroker/protocol.ts +9 -2
  95. package/src/run/channel-session-factory.ts +11 -1
  96. package/src/run/index.ts +41 -7
  97. package/src/server/command-runner.ts +24 -1
  98. package/src/server/index.ts +42 -8
  99. package/src/shared/index.ts +2 -0
  100. package/src/shared/protocol.ts +31 -0
  101. package/src/skills/typeclaw-channels/SKILL.md +4 -4
  102. package/src/skills/typeclaw-config/SKILL.md +2 -2
  103. package/src/skills/typeclaw-memory/SKILL.md +3 -1
  104. package/src/skills/typeclaw-permissions/SKILL.md +3 -3
  105. package/src/skills/typeclaw-skills/SKILL.md +1 -1
  106. package/src/skills/typeclaw-tunnels/SKILL.md +22 -1
  107. package/src/tunnels/providers/cloudflare-quick.ts +65 -7
  108. package/src/tunnels/upstream-probe.ts +25 -0
  109. package/typeclaw.schema.json +156 -67
  110. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +0 -170
  111. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +0 -421
  112. package/src/portbroker/bind-with-forward.ts +0 -102
@@ -2,6 +2,7 @@ import { readdir, stat } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
 
4
4
  import type { MinimalSessionOrigin } from '@/agent/session-meta'
5
+ import type { LiveSessionPayload } from '@/shared'
5
6
 
6
7
  import { previewForHint } from './preview'
7
8
  import { replayJsonl } from './replay'
@@ -13,6 +14,11 @@ export type SessionSummary = {
13
14
  mtimeMs: number
14
15
  origin: MinimalSessionOrigin | null
15
16
  firstPrompt: string | null
17
+ // True only for a registry-derived session with no .jsonl on disk yet (a
18
+ // reply is in flight). Disk sessions leave this undefined. Selecting one tails
19
+ // live-only: streamSessionEvents replays an empty file, then the WS delivers
20
+ // events as they happen.
21
+ live?: boolean
16
22
  }
17
23
 
18
24
  export type ListSessionsOptions = {
@@ -65,6 +71,29 @@ export async function listSessions(opts: ListSessionsOptions): Promise<SessionSu
65
71
  )
66
72
  }
67
73
 
74
+ // Overlay container-registry sessions onto the disk listing. A live session
75
+ // already flushed to disk (post-reply) is dropped from the overlay — the disk
76
+ // summary wins, carrying its real mtime and prompt preview. Only sessions with
77
+ // no .jsonl yet become synthetic live rows, sorted to the top by registration
78
+ // time so an in-flight reply surfaces above settled history.
79
+ export function mergeLiveSessions(disk: SessionSummary[], live: LiveSessionPayload[]): SessionSummary[] {
80
+ const onDisk = new Set(disk.map((s) => s.sessionId))
81
+ const liveOnly = live
82
+ .filter((l) => !onDisk.has(l.sessionId))
83
+ .map(
84
+ (l): SessionSummary => ({
85
+ sessionId: l.sessionId,
86
+ sessionFile: '',
87
+ basename: '',
88
+ mtimeMs: l.registeredAtMs,
89
+ origin: l.origin,
90
+ firstPrompt: null,
91
+ live: true,
92
+ }),
93
+ )
94
+ return [...liveOnly, ...disk].sort((a, b) => b.mtimeMs - a.mtimeMs)
95
+ }
96
+
68
97
  export type ResolveResult =
69
98
  | { ok: true; summary: SessionSummary }
70
99
  | { ok: false; reason: 'not-found' | 'ambiguous'; matches: SessionSummary[] }
@@ -0,0 +1,114 @@
1
+ import { readFile, rename, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ export const EMBEDDING_MODEL_NAME = 'Xenova/multilingual-e5-base'
5
+ export const EMBEDDING_MODEL_DTYPE = 'q8'
6
+ export const EMBEDDING_DIMS = 768
7
+
8
+ // The embedding recipe that makes two vectors comparable: E5 query/passage
9
+ // prefixing + mean pooling + L2 normalize. Stamped in the sentinel (not folded
10
+ // into EMBEDDING_MODEL_ID, which is a stored-row filter — changing the ID would
11
+ // invalidate every existing vector row). A future pooling/normalize change
12
+ // bumps this string so a stale cache fails the sentinel loudly.
13
+ export const EMBEDDING_RECIPE = 'e5-prefix:mean-pool:l2-normalize'
14
+
15
+ // Stored-row identity = name@dtype. Used by the vector store to filter rows
16
+ // from an incompatible model/dtype variant out of cosine scans.
17
+ export const EMBEDDING_MODEL_ID = `${EMBEDDING_MODEL_NAME}@${EMBEDDING_MODEL_DTYPE}`
18
+
19
+ const SENTINEL_FILE = '.typeclaw-model.json'
20
+
21
+ export type ModelSentinel = {
22
+ schemaVersion: 1
23
+ model: string
24
+ dtype: string
25
+ dims: number
26
+ recipe: string
27
+ transformers: string
28
+ }
29
+
30
+ function sentinelPath(dir: string): string {
31
+ return join(dir, SENTINEL_FILE)
32
+ }
33
+
34
+ function expectedSentinel(transformers: string): Omit<ModelSentinel, 'transformers'> & { transformers: string } {
35
+ return {
36
+ schemaVersion: 1,
37
+ model: EMBEDDING_MODEL_NAME,
38
+ dtype: EMBEDDING_MODEL_DTYPE,
39
+ dims: EMBEDDING_DIMS,
40
+ recipe: EMBEDDING_RECIPE,
41
+ transformers,
42
+ }
43
+ }
44
+
45
+ // Atomic write-then-rename so a container reader can never observe a partial
46
+ // JSON file mid-write. Called host-side after a successful model download,
47
+ // inside the proper-lockfile critical section.
48
+ export async function writeModelSentinel(dir: string, input: { transformers: string }): Promise<void> {
49
+ const sentinel = expectedSentinel(input.transformers)
50
+ const tmp = `${sentinelPath(dir)}.${process.pid}.tmp`
51
+ await writeFile(tmp, `${JSON.stringify(sentinel, null, 2)}\n`, 'utf8')
52
+ await rename(tmp, sentinelPath(dir))
53
+ }
54
+
55
+ export async function readModelSentinel(dir: string): Promise<ModelSentinel | null> {
56
+ let raw: string
57
+ try {
58
+ raw = await readFile(sentinelPath(dir), 'utf8')
59
+ } catch {
60
+ return null
61
+ }
62
+ try {
63
+ const parsed = JSON.parse(raw) as Partial<ModelSentinel>
64
+ if (
65
+ parsed.schemaVersion !== 1 ||
66
+ typeof parsed.model !== 'string' ||
67
+ typeof parsed.dtype !== 'string' ||
68
+ typeof parsed.dims !== 'number' ||
69
+ typeof parsed.recipe !== 'string' ||
70
+ typeof parsed.transformers !== 'string'
71
+ ) {
72
+ return null
73
+ }
74
+ return parsed as ModelSentinel
75
+ } catch {
76
+ return null
77
+ }
78
+ }
79
+
80
+ // Throws a TypeClaw-authored error (naming observed vs expected identity, with
81
+ // the fix) BEFORE the container's `local_files_only` pipeline load — so a
82
+ // host/container drift surfaces as a clear "refresh the cache" message instead
83
+ // of a cryptic missing-file miss, OR worse, a stale file that loads against a
84
+ // different producer's layout and silently returns garbage vectors. Absent
85
+ // sentinel is a hard failure: host ensureModels() writes it before `docker
86
+ // run` in the same `typeclaw start`, so a missing one means the mount is wrong
87
+ // or the cache was hand-copied — exactly the case we must not paper over.
88
+ export async function assertModelCacheCompatible(dir: string, expected: { transformers: string }): Promise<void> {
89
+ const sentinel = await readModelSentinel(dir)
90
+ const want = expectedSentinel(expected.transformers)
91
+ if (sentinel === null) {
92
+ throw new Error(
93
+ `TypeClaw model cache at ${dir} is missing or has an unreadable ${SENTINEL_FILE}, so compatibility with ` +
94
+ `this container cannot be verified. Re-run \`typeclaw start\` to refresh the model cache; if it was copied ` +
95
+ `manually, delete it and start again.`,
96
+ )
97
+ }
98
+ const mismatches = describeMismatches(sentinel, want)
99
+ if (mismatches.length > 0) {
100
+ throw new Error(
101
+ `TypeClaw model cache at ${dir} is incompatible with this container (${mismatches.join('; ')}). ` +
102
+ `Re-run \`typeclaw start\` to refresh the model cache.`,
103
+ )
104
+ }
105
+ }
106
+
107
+ function describeMismatches(got: ModelSentinel, want: ModelSentinel): string[] {
108
+ const fields: Array<keyof ModelSentinel> = ['model', 'dtype', 'dims', 'recipe', 'transformers']
109
+ return fields
110
+ .filter((field) => got[field] !== want[field])
111
+ .map(
112
+ (field) => `${field}: cache has ${JSON.stringify(got[field])}, container expects ${JSON.stringify(want[field])}`,
113
+ )
114
+ }
@@ -0,0 +1,55 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, join, parse as parsePath } from 'node:path'
4
+
5
+ // The ACTUALLY-INSTALLED @huggingface/transformers version in the current
6
+ // runtime, read from the resolved package's own package.json — NOT from
7
+ // typeclaw's dependency spec (which is the intended version, not what is on
8
+ // disk). The model-cache sentinel compares this across stages: the host
9
+ // stamps the version that produced the download, the container checks the
10
+ // version that will consume it. Comparing two intended constants would miss
11
+ // exactly the drift this guards — "the installed runtime isn't what the build
12
+ // said it should be" (e.g. a lockfile-free `bun add` resolving a newer
13
+ // release). Resolution is isolated here so the package-internals access lives
14
+ // in one place.
15
+ //
16
+ // We resolve the package's EXPORTED entry and walk up to its package.json,
17
+ // rather than `require('@huggingface/transformers/package.json')`: that subpath
18
+ // is not in the package's `exports` map (only `node`/`default`), so a strict
19
+ // Node-exports resolver throws ERR_PACKAGE_PATH_NOT_EXPORTED. The main entry IS
20
+ // exported, and its package.json is the nearest one above the resolved file.
21
+ export function getResolvedTransformersVersion(): string {
22
+ const require = createRequire(import.meta.url)
23
+ const entry = require.resolve('@huggingface/transformers')
24
+ const version = readNearestPackageVersion(dirname(entry))
25
+ if (version === null) {
26
+ throw new Error('could not resolve @huggingface/transformers version from its package.json')
27
+ }
28
+ return version
29
+ }
30
+
31
+ function readNearestPackageVersion(startDir: string): string | null {
32
+ const root = parsePath(startDir).root
33
+ let dir = startDir
34
+ for (;;) {
35
+ const version = readPackageNameVersion(join(dir, 'package.json'))
36
+ if (version !== null) return version
37
+ if (dir === root) return null
38
+ dir = dirname(dir)
39
+ }
40
+ }
41
+
42
+ // Only accept the @huggingface/transformers package.json, never a nested
43
+ // dependency's: the resolved entry can sit under dist/, and an intermediate
44
+ // dir could in theory carry an unrelated package.json. Match on name.
45
+ function readPackageNameVersion(pkgPath: string): string | null {
46
+ let parsed: { name?: unknown; version?: unknown }
47
+ try {
48
+ parsed = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name?: unknown; version?: unknown }
49
+ } catch {
50
+ return null
51
+ }
52
+ if (parsed.name !== '@huggingface/transformers') return null
53
+ if (typeof parsed.version !== 'string' || parsed.version.length === 0) return null
54
+ return parsed.version
55
+ }
@@ -182,6 +182,9 @@ export type SessionTurnStartEvent = {
182
182
  agentDir: string
183
183
  userPrompt: string
184
184
  origin?: SessionOrigin
185
+ // Mutable ref: plugin writes retrieval results here; server/router reads after hook returns.
186
+ // Only populated when vector.enabled and injection plan is index mode.
187
+ retrievalContext?: { results: string }
185
188
  }
186
189
 
187
190
  export type SessionTurnEndEvent = {
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises'
2
2
 
3
3
  import type { ServerWebSocket } from 'bun'
4
4
 
5
+ import type { ForwardRequestEvent } from './forward-request-bus'
5
6
  import { parseProcNetTcp } from './proc-net-tcp'
6
7
  import { decodeBytes, encodeBytes, type ContainerToHostd, type HostdToContainer, type StreamId } from './protocol'
7
8
 
@@ -23,6 +24,7 @@ export type ContainerBrokerOptions = {
23
24
  // to the host side. Without it, code that picks an in-container port has no
24
25
  // way to detect host-side EADDRINUSE collisions across containers.
25
26
  onForwardResult?: (event: ForwardResultEvent) => void
27
+ onForwardRequestSubscribe?: (cb: (event: ForwardRequestEvent) => void) => () => void
26
28
  }
27
29
 
28
30
  export type ForwardResultEvent =
@@ -72,6 +74,8 @@ export function createContainerBroker(opts: ContainerBrokerOptions): ContainerBr
72
74
  }
73
75
 
74
76
  const sessions = new WeakMap<BrokerSocket, SessionState>()
77
+ const sockets = new Set<BrokerSocket>()
78
+ const reserved = new Map<number, ForwardRequestEvent>()
75
79
 
76
80
  const send = (ws: BrokerSocket, msg: ContainerToHostd): void => {
77
81
  try {
@@ -98,6 +102,22 @@ export function createContainerBroker(opts: ContainerBrokerOptions): ContainerBr
98
102
  }
99
103
  }
100
104
 
105
+ const sendReservedRequest = (ws: BrokerSocket, request: ForwardRequestEvent): void => {
106
+ send(ws, {
107
+ type: 'port-forward-request',
108
+ targetPort: request.targetPort,
109
+ hostCandidates: request.hostCandidates,
110
+ ...(request.reason !== undefined ? { reason: request.reason } : {}),
111
+ })
112
+ }
113
+
114
+ opts.onForwardRequestSubscribe?.((event) => {
115
+ reserved.set(event.targetPort, event)
116
+ for (const ws of sockets) {
117
+ if (ws.data.authed) sendReservedRequest(ws, event)
118
+ }
119
+ })
120
+
101
121
  const tickWatcher = async (ws: BrokerSocket, state: SessionState): Promise<void> => {
102
122
  const next = await snapshotPorts()
103
123
  for (const [port, bindAddr] of next) {
@@ -158,6 +178,7 @@ export function createContainerBroker(opts: ContainerBrokerOptions): ContainerBr
158
178
  return {
159
179
  open(ws) {
160
180
  sessions.set(ws, { pollTimer: null, lastSnapshot: new Map(), upstreams: new Map() })
181
+ sockets.add(ws)
161
182
  },
162
183
 
163
184
  async message(ws, raw) {
@@ -187,6 +208,7 @@ export function createContainerBroker(opts: ContainerBrokerOptions): ContainerBr
187
208
  ws.data.authed = true
188
209
  log({ kind: 'authed' })
189
210
  send(ws, { type: 'broker-hello-ack' })
211
+ for (const request of reserved.values()) sendReservedRequest(ws, request)
190
212
  return
191
213
  }
192
214
 
@@ -252,6 +274,7 @@ export function createContainerBroker(opts: ContainerBrokerOptions): ContainerBr
252
274
  } catch {}
253
275
  }
254
276
  state.upstreams.clear()
277
+ sockets.delete(ws)
255
278
  sessions.delete(ws)
256
279
  },
257
280
  }
@@ -0,0 +1,35 @@
1
+ // In-process event bus for explicit container→host forward requests. The
2
+ // agent-browser plugin runs in the same process as the container broker but
3
+ // does not hold a broker handle, so it publishes here and run/index wires the
4
+ // bus into createContainerBroker.
5
+
6
+ export type ForwardRequestEvent = {
7
+ targetPort: number
8
+ hostCandidates: number[]
9
+ reason?: string
10
+ }
11
+
12
+ type Subscriber = (event: ForwardRequestEvent) => void
13
+
14
+ const subscribers = new Set<Subscriber>()
15
+
16
+ export function publishForwardRequest(event: ForwardRequestEvent): void {
17
+ for (const sub of subscribers) {
18
+ try {
19
+ sub(event)
20
+ } catch {
21
+ // Subscriber failures must not block peer subscribers.
22
+ }
23
+ }
24
+ }
25
+
26
+ export function subscribeForwardRequest(cb: Subscriber): () => void {
27
+ subscribers.add(cb)
28
+ return () => {
29
+ subscribers.delete(cb)
30
+ }
31
+ }
32
+
33
+ export function __resetForwardRequestBus(): void {
34
+ subscribers.clear()
35
+ }
@@ -1,9 +1,8 @@
1
1
  // In-process event bus for `port-forward-result` events emitted by the
2
2
  // host-side broker over the WS to the container side. Lives as a module-level
3
3
  // singleton so the run-loop wiring (src/run/index.ts) can publish events from
4
- // the broker callback while consumers (e.g. the agent-browser plugin's
5
- // bind-with-forward retry loop) subscribe by importing this module without
6
- // needing a reference to the ContainerBroker itself.
4
+ // the broker callback while consumers subscribe by importing this module
5
+ // without needing a reference to the ContainerBroker itself.
7
6
  //
8
7
  // Tests should call `__resetForwardResultBus()` in afterEach so subscriptions
9
8
  // from a previous test don't leak.
@@ -85,13 +85,21 @@ export function createBroker(opts: BrokerOptions): Broker {
85
85
  const listenHost = opts.listenHost ?? defaultListenHost
86
86
 
87
87
  type ForwarderState = {
88
- port: number
88
+ targetPort: number
89
+ hostPort: number
89
90
  bindAddr: BindAddr
91
+ reserved: boolean
90
92
  listener: HostListener
91
93
  streams: Map<StreamId, { sock: HostSocket; opened: boolean; pending: Uint8Array[] }>
92
94
  }
93
95
 
94
96
  const forwarders = new Map<number, ForwarderState>()
97
+ const reservedTargets = new Map<number, number>()
98
+ // Targets claimed by a reserved forward whose host bind is still in flight.
99
+ // Marked synchronously BEFORE awaiting listenHost() so a concurrent
100
+ // port-listen snapshot/opened for the same target cannot slip past the
101
+ // reserved guard and install a competing auto-forward during the await.
102
+ const pendingReservedTargets = new Set<number>()
95
103
  let ws: WsClient | null = null
96
104
  let nextStreamId: StreamId = 1
97
105
  let stopped = false
@@ -112,8 +120,8 @@ export function createBroker(opts: BrokerOptions): Broker {
112
120
  }
113
121
  }
114
122
 
115
- const closeStream = (port: number, streamId: StreamId, sendClose: boolean): void => {
116
- const fwd = forwarders.get(port)
123
+ const closeStream = (hostPort: number, streamId: StreamId, sendClose: boolean): void => {
124
+ const fwd = forwarders.get(hostPort)
117
125
  if (!fwd) return
118
126
  const stream = fwd.streams.get(streamId)
119
127
  if (!stream) return
@@ -124,7 +132,7 @@ export function createBroker(opts: BrokerOptions): Broker {
124
132
  if (sendClose && ws) ws.send({ type: 'relay-close', streamId, side: 'downstream' })
125
133
  }
126
134
 
127
- const handleHostConnection = (port: number, sock: HostSocket): void => {
135
+ const handleHostConnection = (hostPort: number, sock: HostSocket): void => {
128
136
  if (!ws) {
129
137
  try {
130
138
  sock.end()
@@ -132,7 +140,7 @@ export function createBroker(opts: BrokerOptions): Broker {
132
140
  return
133
141
  }
134
142
  const streamId = allocStreamId()
135
- const fwd = forwarders.get(port)
143
+ const fwd = forwarders.get(hostPort)
136
144
  if (!fwd) {
137
145
  try {
138
146
  sock.end()
@@ -153,53 +161,174 @@ export function createBroker(opts: BrokerOptions): Broker {
153
161
  }
154
162
  })
155
163
  sock.onClose(() => {
156
- closeStream(port, streamId, true)
164
+ closeStream(hostPort, streamId, true)
157
165
  })
158
166
 
159
- if (ws) ws.send({ type: 'relay-open', streamId, port })
167
+ if (ws) ws.send({ type: 'relay-open', streamId, port: fwd.targetPort })
160
168
  }
161
169
 
162
- const installForwarder = async (port: number, bindAddr: BindAddr): Promise<void> => {
163
- if (forwarders.has(port)) return
164
- if (!shouldForward({ policy: opts.policy, port })) {
165
- // Policy excluded the port. Tell the container so it can stop waiting
166
- // (e.g. the agent-browser plugin's bind-with-forward retry loop). Without
167
- // this, the container would block on the forward-result timeout for
168
- // every policy-denied port.
169
- if (ws) ws.send({ type: 'port-forward-result', port, ok: false, reason: 'policy excluded' })
170
+ const isReservedTarget = (targetPort: number): boolean =>
171
+ reservedTargets.has(targetPort) || pendingReservedTargets.has(targetPort)
172
+
173
+ const hasAutoForwarderForTarget = (targetPort: number): boolean => {
174
+ for (const fwd of forwarders.values()) {
175
+ if (!fwd.reserved && fwd.targetPort === targetPort) return true
176
+ }
177
+ return false
178
+ }
179
+
180
+ const installForwarder = async (targetPort: number, bindAddr: BindAddr): Promise<void> => {
181
+ if (isReservedTarget(targetPort)) return
182
+ // Dedup by targetPort, not the map key: the map is keyed by the bound host
183
+ // port, which diverges from targetPort on an ephemeral bind, so a
184
+ // `forwarders.has(targetPort)` guard would miss an existing forward and bind
185
+ // a second host listener for the same container port.
186
+ if (hasAutoForwarderForTarget(targetPort)) return
187
+ if (!shouldForward({ policy: opts.policy, port: targetPort })) {
188
+ // Policy excluded the port. Tell the container so consumers waiting on
189
+ // port-forward-result can surface a diagnostic instead of hanging.
190
+ if (ws) ws.send({ type: 'port-forward-result', port: targetPort, ok: false, reason: 'policy excluded' })
170
191
  return
171
192
  }
172
193
  try {
173
- const listener = await listenHost(hostBind, port, {
174
- onConnection: (sock) => handleHostConnection(port, sock),
194
+ // The host listen port — not targetPort — is the forwarders map key, so
195
+ // the connection callback must look up by it. They coincide for ordinary
196
+ // 1:1 auto-forwards but diverge whenever the OS reassigns the bind (e.g. a
197
+ // port-0 ephemeral bind), at which point routing by targetPort misses the
198
+ // map and silently drops the connection. Captured after the await; a
199
+ // connection can only arrive once the listener is bound.
200
+ let boundHostPort: number | undefined
201
+ const listener = await listenHost(hostBind, targetPort, {
202
+ onConnection: (sock) => {
203
+ if (boundHostPort === undefined) {
204
+ try {
205
+ sock.end()
206
+ } catch {}
207
+ return
208
+ }
209
+ handleHostConnection(boundHostPort, sock)
210
+ },
211
+ })
212
+ boundHostPort = listener.port
213
+ forwarders.set(boundHostPort, {
214
+ targetPort,
215
+ hostPort: boundHostPort,
216
+ bindAddr,
217
+ reserved: false,
218
+ listener,
219
+ streams: new Map(),
175
220
  })
176
- forwarders.set(port, { port, bindAddr, listener, streams: new Map() })
177
- emit({ kind: 'port-forward-opened', containerName: opts.containerName, port, bindAddr })
178
- if (ws) ws.send({ type: 'port-forward-result', port, ok: true, hostPort: listener.port })
221
+ emit({
222
+ kind: 'port-forward-opened',
223
+ containerName: opts.containerName,
224
+ port: targetPort,
225
+ hostPort: boundHostPort,
226
+ bindAddr,
227
+ })
228
+ if (ws) ws.send({ type: 'port-forward-result', port: targetPort, ok: true, hostPort: boundHostPort })
179
229
  } catch (err) {
180
230
  const reason = err instanceof Error ? err.message : String(err)
181
- log(`forward bind ${port}: ${reason}`)
182
- emit({ kind: 'port-forward-failed', containerName: opts.containerName, port, reason })
183
- if (ws) ws.send({ type: 'port-forward-result', port, ok: false, reason })
231
+ log(`forward bind ${targetPort}: ${reason}`)
232
+ emit({ kind: 'port-forward-failed', containerName: opts.containerName, port: targetPort, reason })
233
+ if (ws) ws.send({ type: 'port-forward-result', port: targetPort, ok: false, reason })
234
+ }
235
+ }
236
+
237
+ const installReservedForwarder = async (targetPort: number, hostCandidates: number[]): Promise<void> => {
238
+ const existingHostPort = reservedTargets.get(targetPort)
239
+ if (existingHostPort !== undefined) {
240
+ if (ws) ws.send({ type: 'port-forward-result', port: targetPort, ok: true, hostPort: existingHostPort })
241
+ return
242
+ }
243
+ if (!shouldForward({ policy: opts.policy, port: targetPort })) {
244
+ if (ws) ws.send({ type: 'port-forward-result', port: targetPort, ok: false, reason: 'policy excluded' })
245
+ return
246
+ }
247
+
248
+ // Claim the target and evict any auto-forward that already won the race
249
+ // before the reserved bind below yields control to the event loop.
250
+ pendingReservedTargets.add(targetPort)
251
+ removeAutoForwarderForTarget(targetPort, 'container-released')
252
+
253
+ let lastReason = 'no host candidates'
254
+ for (const hostPort of hostCandidates) {
255
+ try {
256
+ // `port` stays the in-container target; the host listen port is the
257
+ // forwarders map key the connection callback must route by. Reserved
258
+ // forwards deliberately allow the two to differ, so route by the actual
259
+ // bound port, captured after the await.
260
+ let boundHostPort: number | undefined
261
+ const listener = await listenHost(hostBind, hostPort, {
262
+ onConnection: (sock) => {
263
+ if (boundHostPort === undefined) {
264
+ try {
265
+ sock.end()
266
+ } catch {}
267
+ return
268
+ }
269
+ handleHostConnection(boundHostPort, sock)
270
+ },
271
+ })
272
+ boundHostPort = listener.port
273
+ forwarders.set(boundHostPort, {
274
+ targetPort,
275
+ hostPort: boundHostPort,
276
+ bindAddr: '127.0.0.1',
277
+ reserved: true,
278
+ listener,
279
+ streams: new Map(),
280
+ })
281
+ reservedTargets.set(targetPort, boundHostPort)
282
+ pendingReservedTargets.delete(targetPort)
283
+ emit({
284
+ kind: 'port-forward-opened',
285
+ containerName: opts.containerName,
286
+ port: targetPort,
287
+ hostPort: boundHostPort,
288
+ bindAddr: '127.0.0.1',
289
+ })
290
+ if (ws) ws.send({ type: 'port-forward-result', port: targetPort, ok: true, hostPort: boundHostPort })
291
+ return
292
+ } catch (err) {
293
+ lastReason = err instanceof Error ? err.message : String(err)
294
+ log(`reserved forward bind ${hostPort} for ${targetPort}: ${lastReason}`)
295
+ }
184
296
  }
297
+ pendingReservedTargets.delete(targetPort)
298
+ emit({ kind: 'port-forward-failed', containerName: opts.containerName, port: targetPort, reason: lastReason })
299
+ if (ws) ws.send({ type: 'port-forward-result', port: targetPort, ok: false, reason: lastReason })
185
300
  }
186
301
 
187
- const removeForwarder = (port: number, reason: 'container-released' | 'host-error'): void => {
188
- const fwd = forwarders.get(port)
302
+ const removeForwarder = (hostPort: number, reason: 'container-released' | 'host-error'): void => {
303
+ const fwd = forwarders.get(hostPort)
189
304
  if (!fwd) return
190
- forwarders.delete(port)
305
+ forwarders.delete(hostPort)
306
+ if (fwd.reserved) reservedTargets.delete(fwd.targetPort)
191
307
  try {
192
308
  fwd.listener.stop()
193
309
  } catch {}
194
- for (const [streamId] of fwd.streams) closeStream(port, streamId, false)
195
- emit({ kind: 'port-forward-closed', containerName: opts.containerName, port, reason })
310
+ for (const [streamId] of fwd.streams) closeStream(hostPort, streamId, false)
311
+ emit({
312
+ kind: 'port-forward-closed',
313
+ containerName: opts.containerName,
314
+ port: fwd.targetPort,
315
+ hostPort: fwd.hostPort,
316
+ reason,
317
+ })
318
+ }
319
+
320
+ const removeAutoForwarderForTarget = (targetPort: number, reason: 'container-released' | 'host-error'): void => {
321
+ for (const [hostPort, fwd] of Array.from(forwarders)) {
322
+ if (!fwd.reserved && fwd.targetPort === targetPort) removeForwarder(hostPort, reason)
323
+ }
196
324
  }
197
325
 
198
326
  const teardownAllForwarders = (reason: 'broker-stopped' | 'deregistered' | 'host-error'): void => {
199
- for (const port of Array.from(forwarders.keys())) {
200
- const fwd = forwarders.get(port)
327
+ for (const hostPort of Array.from(forwarders.keys())) {
328
+ const fwd = forwarders.get(hostPort)
201
329
  if (!fwd) continue
202
- forwarders.delete(port)
330
+ forwarders.delete(hostPort)
331
+ if (fwd.reserved) reservedTargets.delete(fwd.targetPort)
203
332
  try {
204
333
  fwd.listener.stop()
205
334
  } catch {}
@@ -209,7 +338,19 @@ export function createBroker(opts: BrokerOptions): Broker {
209
338
  stream?.sock.end()
210
339
  } catch {}
211
340
  }
212
- emit({ kind: 'port-forward-closed', containerName: opts.containerName, port, reason })
341
+ emit({
342
+ kind: 'port-forward-closed',
343
+ containerName: opts.containerName,
344
+ port: fwd.targetPort,
345
+ hostPort: fwd.hostPort,
346
+ reason,
347
+ })
348
+ }
349
+ }
350
+
351
+ const teardownAutoForwarders = (reason: 'host-error'): void => {
352
+ for (const [hostPort, fwd] of Array.from(forwarders)) {
353
+ if (!fwd.reserved) removeForwarder(hostPort, reason)
213
354
  }
214
355
  }
215
356
 
@@ -228,14 +369,19 @@ export function createBroker(opts: BrokerOptions): Broker {
228
369
  return
229
370
  case 'port-listen-snapshot':
230
371
  for (const { port, bindAddr } of msg.ports) {
231
- void installForwarder(port, bindAddr)
372
+ if (!isReservedTarget(port)) void installForwarder(port, bindAddr)
232
373
  }
233
374
  return
234
375
  case 'port-listen-opened':
376
+ if (isReservedTarget(msg.port)) return
235
377
  void installForwarder(msg.port, msg.bindAddr)
236
378
  return
237
379
  case 'port-listen-closed':
238
- removeForwarder(msg.port, 'container-released')
380
+ if (isReservedTarget(msg.port)) return
381
+ removeAutoForwarderForTarget(msg.port, 'container-released')
382
+ return
383
+ case 'port-forward-request':
384
+ void installReservedForwarder(msg.targetPort, msg.hostCandidates)
239
385
  return
240
386
  case 'relay-open-ack': {
241
387
  const port = findStreamPort(msg.streamId)
@@ -303,7 +449,7 @@ export function createBroker(opts: BrokerOptions): Broker {
303
449
  client.onMessage(handleContainerMessage)
304
450
  client.onClose(() => {
305
451
  ws = null
306
- teardownAllForwarders('host-error')
452
+ teardownAutoForwarders('host-error')
307
453
  scheduleReconnect()
308
454
  })
309
455
 
@@ -404,7 +550,7 @@ async function defaultConnectWs(url: string): Promise<WsClient> {
404
550
  })
405
551
  }
406
552
 
407
- function defaultListenHost(
553
+ export function defaultListenHost(
408
554
  host: string,
409
555
  port: number,
410
556
  handlers: { onConnection: (sock: HostSocket) => void },
@@ -20,8 +20,13 @@ export {
20
20
  type UpstreamConnection,
21
21
  type UpstreamHandlers,
22
22
  } from './container-server'
23
+ export {
24
+ __resetForwardRequestBus,
25
+ publishForwardRequest,
26
+ subscribeForwardRequest,
27
+ type ForwardRequestEvent,
28
+ } from './forward-request-bus'
23
29
  export { __resetForwardResultBus, publishForwardResult, subscribeForwardResult } from './forward-result-bus'
24
- export { bindWithForward, type BindFactory, type BindResult, type BindWithForwardOptions } from './bind-with-forward'
25
30
  export {
26
31
  createBroker,
27
32
  type Broker,