typeclaw 0.36.8 → 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 (111) 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 +121 -34
  74. package/src/init/gitignore.ts +7 -7
  75. package/src/init/index.ts +41 -9
  76. package/src/init/models-dev.ts +96 -21
  77. package/src/init/oauth-login.ts +3 -3
  78. package/src/init/progress.ts +29 -0
  79. package/src/init/validate-api-key.ts +4 -0
  80. package/src/inspect/index.ts +13 -6
  81. package/src/inspect/item-list.ts +11 -2
  82. package/src/inspect/live-list.ts +65 -0
  83. package/src/inspect/open-item.ts +22 -1
  84. package/src/inspect/session-list.ts +29 -0
  85. package/src/models/embedding-model.ts +114 -0
  86. package/src/models/transformers-version.ts +55 -0
  87. package/src/plugin/types.ts +3 -0
  88. package/src/portbroker/container-server.ts +23 -0
  89. package/src/portbroker/forward-request-bus.ts +35 -0
  90. package/src/portbroker/forward-result-bus.ts +2 -3
  91. package/src/portbroker/hostd-client.ts +182 -36
  92. package/src/portbroker/index.ts +6 -1
  93. package/src/portbroker/protocol.ts +9 -2
  94. package/src/run/channel-session-factory.ts +11 -1
  95. package/src/run/index.ts +41 -7
  96. package/src/server/command-runner.ts +24 -1
  97. package/src/server/index.ts +42 -8
  98. package/src/shared/index.ts +2 -0
  99. package/src/shared/protocol.ts +31 -0
  100. package/src/skills/typeclaw-channels/SKILL.md +4 -4
  101. package/src/skills/typeclaw-config/SKILL.md +2 -2
  102. package/src/skills/typeclaw-memory/SKILL.md +3 -1
  103. package/src/skills/typeclaw-permissions/SKILL.md +3 -3
  104. package/src/skills/typeclaw-skills/SKILL.md +1 -1
  105. package/src/skills/typeclaw-tunnels/SKILL.md +22 -1
  106. package/src/tunnels/providers/cloudflare-quick.ts +65 -7
  107. package/src/tunnels/upstream-probe.ts +25 -0
  108. package/typeclaw.schema.json +156 -67
  109. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +0 -170
  110. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +0 -421
  111. package/src/portbroker/bind-with-forward.ts +0 -102
@@ -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,
@@ -15,6 +15,7 @@ export type HostdToContainer =
15
15
  export type ContainerToHostd =
16
16
  | { type: 'broker-hello-ack' }
17
17
  | { type: 'broker-hello-nack'; reason: string }
18
+ | { type: 'port-forward-request'; targetPort: number; hostCandidates: number[]; reason?: string }
18
19
  | { type: 'port-listen-snapshot'; ports: Array<{ port: number; bindAddr: BindAddr }> }
19
20
  | { type: 'port-listen-opened'; port: number; bindAddr: BindAddr }
20
21
  | { type: 'port-listen-closed'; port: number }
@@ -24,8 +25,14 @@ export type ContainerToHostd =
24
25
  | { type: 'relay-close'; streamId: StreamId; side: 'upstream' | 'downstream' }
25
26
 
26
27
  export type PortForwardEvent =
27
- | { kind: 'port-forward-opened'; containerName: string; port: number; bindAddr: BindAddr }
28
- | { kind: 'port-forward-closed'; containerName: string; port: number; reason: PortForwardCloseReason }
28
+ | { kind: 'port-forward-opened'; containerName: string; port: number; hostPort: number; bindAddr: BindAddr }
29
+ | {
30
+ kind: 'port-forward-closed'
31
+ containerName: string
32
+ port: number
33
+ hostPort: number
34
+ reason: PortForwardCloseReason
35
+ }
29
36
  | { kind: 'port-forward-failed'; containerName: string; port: number; reason: string }
30
37
 
31
38
  export type PortForwardCloseReason = 'container-released' | 'host-error' | 'deregistered' | 'broker-stopped'
@@ -3,6 +3,7 @@ import { SessionManager } from '@mariozechner/pi-coding-agent'
3
3
  import { createSession as defaultCreateSession } from '@/agent'
4
4
  import type { LiveSessionRegistry } from '@/agent/live-sessions'
5
5
  import type { LiveSubagentRegistry } from '@/agent/live-subagents'
6
+ import { sessionMetaPayload } from '@/agent/session-meta'
6
7
  import type { CreateSessionForSubagent, SubagentRegistry } from '@/agent/subagents'
7
8
  import { capJsonlFileInPlace } from '@/bundled-plugins/tool-result-cap/cap-jsonl'
8
9
  import type { CapOptions } from '@/bundled-plugins/tool-result-cap/cap-result'
@@ -70,6 +71,9 @@ export type BuildChannelSessionFactoryDeps = {
70
71
  subagentRegistry?: SubagentRegistry
71
72
  getCreateSessionForSubagent?: () => CreateSessionForSubagent
72
73
  liveSessionRegistry?: LiveSessionRegistry
74
+ // Forwarded to createSession: when true the `# Memory` section is omitted
75
+ // from the system prompt (vector agents inject memory per-turn instead).
76
+ suppressSystemMemory?: boolean
73
77
  }
74
78
 
75
79
  // Tight basename validation so a tampered or corrupt channels/sessions.json
@@ -137,10 +141,16 @@ export function buildChannelSessionFactory(deps: BuildChannelSessionFactoryDeps)
137
141
  ...(deps.getCreateSessionForSubagent !== undefined
138
142
  ? { createSessionForSubagent: deps.getCreateSessionForSubagent() }
139
143
  : {}),
144
+ ...(deps.suppressSystemMemory !== undefined ? { suppressSystemMemory: deps.suppressSystemMemory } : {}),
140
145
  })
141
146
 
142
147
  const sessionId = sessionManager.getSessionId()
143
- deps.liveSessionRegistry?.register({ sessionId, session })
148
+ deps.liveSessionRegistry?.register({
149
+ sessionId,
150
+ session,
151
+ origin: sessionMetaPayload(origin).origin,
152
+ registeredAtMs: Date.now(),
153
+ })
144
154
 
145
155
  return {
146
156
  session,
package/src/run/index.ts CHANGED
@@ -5,6 +5,7 @@ import { LiveSessionRegistry } from '@/agent/live-sessions'
5
5
  import { LiveSubagentRegistry } from '@/agent/live-subagents'
6
6
  import { requestContainerRestart } from '@/agent/restart'
7
7
  import { consumeRestartHandoff } from '@/agent/restart-handoff'
8
+ import { sessionMetaPayload } from '@/agent/session-meta'
8
9
  import type { SessionOrigin } from '@/agent/session-origin'
9
10
  import {
10
11
  awaitWithSubagentTimeout,
@@ -18,6 +19,9 @@ import {
18
19
  type SubagentShared,
19
20
  } from '@/agent/subagents'
20
21
  import { clearTodosForOrigin } from '@/agent/todo/continuation-wiring'
22
+ import { vectorEnabledFromMemoryConfig } from '@/bundled-plugins/memory/vector/config'
23
+ import { embed, warmEmbedder } from '@/bundled-plugins/memory/vector/embedder'
24
+ import { buildStartupVectorIndex } from '@/bundled-plugins/memory/vector/startup'
21
25
  import { resolveCapOptionsFromConfig } from '@/bundled-plugins/tool-result-cap'
22
26
  import {
23
27
  createChannelManager,
@@ -55,7 +59,7 @@ import { runStartupMigrations } from '@/migrations'
55
59
  import { loadPlugins, type LoadPluginsResult, pluginCronJobs, type PluginRegistry, summarizeLoaded } from '@/plugin'
56
60
  import { createPluginLogger } from '@/plugin/context'
57
61
  import type { CronHandlerContext } from '@/plugin/types'
58
- import { createContainerBroker, publishForwardResult } from '@/portbroker'
62
+ import { createContainerBroker, publishForwardResult, subscribeForwardRequest } from '@/portbroker'
59
63
  import { formatChannelReloadSummary, ReloadRegistry } from '@/reload'
60
64
  import { createClaimController } from '@/role-claim'
61
65
  import {
@@ -157,6 +161,10 @@ export async function startAgent({
157
161
  const tuiTokenOpt = tuiToken !== undefined && tuiToken !== '' ? { tuiToken } : {}
158
162
 
159
163
  const pluginConfigsByName = loadPluginConfigsSync(cwd)
164
+ // Vector agents omit the system-prompt `# Memory` section and inject memory
165
+ // per-turn instead. Derived once here: `memory.vector.enabled` is
166
+ // restart-required, so a single boot read is coherent for the process.
167
+ const suppressSystemMemory = vectorEnabledFromMemoryConfig(pluginConfigsByName.memory)
160
168
  const cwdConfig = loadConfigSync(cwd)
161
169
  const githubTokenBridge = createGithubTokenBridge()
162
170
  const mcpManager =
@@ -215,6 +223,19 @@ export async function startAgent({
215
223
  // v2-only parser rejects the file and hydrate below sees no channels. Runs
216
224
  // exactly once per folder; a folder already at v2 is a no-op.
217
225
  runStartupMigrations(cwd)
226
+ if (suppressSystemMemory) {
227
+ await buildStartupVectorIndex(cwd, embed).catch((err) => {
228
+ console.warn(`[vector] startup index build failed: ${err instanceof Error ? err.message : String(err)}`)
229
+ })
230
+
231
+ // Warm the embedder now (even when the index needed no rebuild above, which
232
+ // skips embed() entirely) so the first channel turn's query embed doesn't
233
+ // pay the one-time ONNX init on its critical path. Non-fatal: a failure here
234
+ // degrades to the per-turn lazy load, same as before this step existed.
235
+ await warmEmbedder().catch((err) => {
236
+ console.warn(`[vector] embedder warm-up failed: ${err instanceof Error ? err.message : String(err)}`)
237
+ })
238
+ }
218
239
 
219
240
  // Channel adapters read `process.env[TOKEN_ENV]` (see channels/manager.ts).
220
241
  // Hydrate fills any unset env var from secrets.json#channels via env-wins:
@@ -280,6 +301,7 @@ export async function startAgent({
280
301
  stream,
281
302
  reloadRegistry,
282
303
  pluginRuntime,
304
+ suppressSystemMemory,
283
305
  getChannelRouter: () => channelManager.router,
284
306
  rehydrateCapOptions: resolveCapOptionsFromConfig(pluginConfigsByName['tool-result-cap']),
285
307
  permissions: pluginsLoaded.permissions,
@@ -394,7 +416,12 @@ export async function startAgent({
394
416
  ...(entry.pluginSubagent.bashPolicy !== undefined ? { bashPolicy: entry.pluginSubagent.bashPolicy } : {}),
395
417
  ...runtimeVersionOpt,
396
418
  })
397
- liveSessionRegistry.register({ sessionId, session: created.session })
419
+ liveSessionRegistry.register({
420
+ sessionId,
421
+ session: created.session,
422
+ origin: sessionMetaPayload(origin).origin,
423
+ registeredAtMs: Date.now(),
424
+ })
398
425
  const originalDispose = created.dispose
399
426
  return {
400
427
  ...created,
@@ -485,6 +512,7 @@ export async function startAgent({
485
512
  containerName: containerNameOpt.containerName,
486
513
  sessionFactory,
487
514
  channelRouter: channelManager.router,
515
+ suppressSystemMemory,
488
516
  ...mcpManagerOpt,
489
517
  }),
490
518
  subagent: (subName: string, payload?: unknown) =>
@@ -525,6 +553,7 @@ export async function startAgent({
525
553
  channelRouter: channelManager.router,
526
554
  origin: cronOrigin,
527
555
  permissions: pluginsLoaded.permissions,
556
+ suppressSystemMemory,
528
557
  ...(refOverride !== undefined ? { refOverride } : {}),
529
558
  ...(snap.hasAnyPluginContent
530
559
  ? {
@@ -543,7 +572,12 @@ export async function startAgent({
543
572
  ...runtimeVersionOpt,
544
573
  ...mcpManagerOpt,
545
574
  })
546
- liveSessionRegistry.register({ sessionId, session })
575
+ liveSessionRegistry.register({
576
+ sessionId,
577
+ session,
578
+ origin: sessionMetaPayload(cronOrigin).origin,
579
+ registeredAtMs: Date.now(),
580
+ })
547
581
  return {
548
582
  prompt: (text) => session.prompt(text),
549
583
  dispose: () => {
@@ -729,11 +763,10 @@ export async function startAgent({
729
763
  payload: { kind: 'portbroker-log', event },
730
764
  })
731
765
  },
732
- // Re-publish to the in-process bus so consumers (today: the
733
- // agent-browser plugin's bind-with-forward retry loop) can subscribe
734
- // without holding a reference to the broker. See src/portbroker/
735
- // forward-result-bus.ts for the contract.
766
+ // Re-publish to in-process buses so plugin code can talk to the
767
+ // broker without holding a ContainerBroker reference.
736
768
  onForwardResult: (event) => publishForwardResult(event),
769
+ onForwardRequestSubscribe: (cb) => subscribeForwardRequest(cb),
737
770
  })
738
771
  : undefined
739
772
  const containerBrokerOpt = containerBroker ? { containerBroker } : {}
@@ -749,6 +782,7 @@ export async function startAgent({
749
782
  outbound,
750
783
  sessionFactory,
751
784
  channelRouter: channelManager.router,
785
+ suppressSystemMemory,
752
786
  ...mcpManagerOpt,
753
787
  })
754
788