typeclaw 0.10.0 → 0.11.1

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 (62) hide show
  1. package/README.md +5 -1
  2. package/package.json +1 -1
  3. package/src/agent/index.ts +37 -4
  4. package/src/agent/multimodal/look-at.ts +8 -0
  5. package/src/agent/restart-handoff/index.ts +91 -0
  6. package/src/agent/restart-handoff/paths.ts +11 -0
  7. package/src/agent/session-origin.ts +30 -10
  8. package/src/agent/subagent-completion-reminder.ts +4 -2
  9. package/src/agent/system-prompt.ts +3 -1
  10. package/src/agent/tools/restart.ts +42 -1
  11. package/src/agent/tools/skip-response.ts +157 -0
  12. package/src/bundled-plugins/memory/README.md +18 -2
  13. package/src/bundled-plugins/memory/index.ts +108 -6
  14. package/src/bundled-plugins/memory/memory-logger.ts +33 -24
  15. package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
  16. package/src/channels/adapters/discord-bot-invite.ts +89 -0
  17. package/src/channels/adapters/github/auth-app.ts +53 -9
  18. package/src/channels/adapters/github/auth-pat.ts +4 -1
  19. package/src/channels/adapters/github/auth.ts +10 -0
  20. package/src/channels/adapters/github/event-permissions.ts +83 -0
  21. package/src/channels/adapters/github/inbound.ts +126 -1
  22. package/src/channels/adapters/github/index.ts +60 -66
  23. package/src/channels/adapters/github/outbound.ts +65 -17
  24. package/src/channels/adapters/github/permission-guidance.ts +169 -0
  25. package/src/channels/adapters/github/team-membership.ts +56 -0
  26. package/src/channels/adapters/kakaotalk-classify.ts +13 -1
  27. package/src/channels/adapters/kakaotalk.ts +2 -0
  28. package/src/channels/router.ts +269 -34
  29. package/src/channels/schema.ts +8 -7
  30. package/src/channels/types.ts +1 -1
  31. package/src/cli/channel.ts +138 -52
  32. package/src/cli/init.ts +139 -100
  33. package/src/cli/inspect-controller.ts +66 -0
  34. package/src/cli/inspect.ts +24 -32
  35. package/src/cli/prompt-pem.ts +113 -0
  36. package/src/cli/run.ts +24 -5
  37. package/src/cli/tui.ts +34 -10
  38. package/src/cli/tunnel.ts +453 -14
  39. package/src/cli/ui.ts +22 -0
  40. package/src/compose/discover.ts +5 -0
  41. package/src/config/config.ts +35 -7
  42. package/src/config/providers.ts +64 -56
  43. package/src/init/env-file.ts +66 -0
  44. package/src/init/hatching.ts +32 -5
  45. package/src/init/index.ts +131 -39
  46. package/src/init/validate-api-key.ts +31 -0
  47. package/src/inspect/index.ts +5 -1
  48. package/src/inspect/loop.ts +12 -1
  49. package/src/inspect/replay.ts +15 -1
  50. package/src/run/codex-fetch-observer.ts +377 -0
  51. package/src/run/index.ts +14 -2
  52. package/src/server/command-runner.ts +31 -2
  53. package/src/server/index.ts +59 -1
  54. package/src/shared/protocol.ts +1 -1
  55. package/src/skills/typeclaw-channel-github/SKILL.md +47 -1
  56. package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
  57. package/src/tui/index.ts +17 -5
  58. package/src/tunnels/index.ts +1 -0
  59. package/src/tunnels/manager.ts +18 -0
  60. package/src/tunnels/providers/cloudflare-named.ts +224 -0
  61. package/src/tunnels/types.ts +17 -1
  62. package/typeclaw.schema.json +25 -7
@@ -60,6 +60,23 @@ export async function validateApiKey(
60
60
  return { kind: 'skipped', reason: 'network-error', detail: 'unexpected response shape' }
61
61
  }
62
62
  if (res.status === 401 || res.status === 403) {
63
+ // Fireworks issues two key classes that probe the same /v1/models
64
+ // endpoint differently:
65
+ // * Standard keys (fw_...) → 200 with the models list
66
+ // * Fire Pass keys (fpk_...) → 403 with {"error":{"code":"FORBIDDEN",
67
+ // "message":"Fire Pass API keys are not authorized for this route."}}
68
+ // The 403 *proves* authentication succeeded — the route is just out of
69
+ // scope for the key. Fire Pass keys do work at chat-completions, which
70
+ // is exactly the surface typeclaw needs (the only Fireworks model wired
71
+ // here is the Fire Pass router `kimi-k2p6-turbo`). Treating that 403
72
+ // as `rejected` is the bug; recognize the marker and accept the key.
73
+ // Genuinely bad keys still come back as 401 UNAUTHORIZED, untouched.
74
+ if (providerId === 'fireworks' && res.status === 403) {
75
+ const body = await readCapped(res, MAX_BODY_BYTES)
76
+ if (body !== null && isFireworksFirePassForbidden(body)) {
77
+ return { kind: 'ok' }
78
+ }
79
+ }
63
80
  return { kind: 'rejected', status: res.status }
64
81
  }
65
82
  return { kind: 'skipped', reason: 'network-error', detail: `HTTP ${res.status}` }
@@ -74,6 +91,20 @@ export async function validateApiKey(
74
91
 
75
92
  const MAX_BODY_BYTES = 4096
76
93
 
94
+ function isFireworksFirePassForbidden(body: string): boolean {
95
+ try {
96
+ const parsed = JSON.parse(body) as { error?: { code?: unknown; message?: unknown } }
97
+ const err = parsed.error
98
+ if (!err || typeof err !== 'object') return false
99
+ if (err.code === 'FORBIDDEN' && typeof err.message === 'string' && err.message.includes('Fire Pass')) {
100
+ return true
101
+ }
102
+ return false
103
+ } catch {
104
+ return false
105
+ }
106
+ }
107
+
77
108
  async function isModelsListShape(res: Response): Promise<boolean> {
78
109
  const text = await readCapped(res, MAX_BODY_BYTES)
79
110
  if (text === null) return false
@@ -37,7 +37,11 @@ export type RunInspectOptions = {
37
37
  liveHint?: string
38
38
  }
39
39
 
40
- export type SelectSession = (sessions: SessionSummary[]) => Promise<SessionSummary | null>
40
+ export type SelectSessionOptions = {
41
+ initialSessionId?: string
42
+ }
43
+
44
+ export type SelectSession = (sessions: SessionSummary[], opts?: SelectSessionOptions) => Promise<SessionSummary | null>
41
45
 
42
46
  export type LiveSourceFactory = (opts: {
43
47
  sessionId: string
@@ -6,9 +6,20 @@ export type RunInspectLoopOptions = Omit<RunInspectOptions, 'escSignal'> & {
6
6
 
7
7
  export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunInspectResult> {
8
8
  let sessionArg = opts.sessionIdOrPrefix
9
+ // Remember the last session the user picked from the interactive picker so
10
+ // an ESC-back-to-picker re-opens with that row pre-selected. The picker
11
+ // receives this through the `initialSessionId` hint on its second arg.
12
+ let lastPickedId: string | undefined
13
+ const wrappedSelectSession: typeof opts.selectSession = async (sessions, selectOpts) => {
14
+ const hint = selectOpts?.initialSessionId ?? lastPickedId
15
+ const picked = await opts.selectSession(sessions, hint !== undefined ? { initialSessionId: hint } : {})
16
+ if (picked !== null) lastPickedId = picked.sessionId
17
+ return picked
18
+ }
19
+
9
20
  while (true) {
10
21
  const escSignal = opts.newEscSignal()
11
- const callOpts: RunInspectOptions = { ...opts, escSignal }
22
+ const callOpts: RunInspectOptions = { ...opts, escSignal, selectSession: wrappedSelectSession }
12
23
  if (sessionArg !== undefined) callOpts.sessionIdOrPrefix = sessionArg
13
24
  else delete (callOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
14
25
 
@@ -66,7 +66,7 @@ function* eventsFromEntry(
66
66
  if (!isMessageEntry(entry)) return
67
67
  const message = entry.message
68
68
  const role = message.role
69
- const ts = numberOr(readField(message, 'timestamp'), 0)
69
+ const ts = entryTimestampMs(entry, message)
70
70
  if (role === 'user') {
71
71
  const text = readTextContent(message.content)
72
72
  if (text !== null) yield { cat: 'user', ts, text }
@@ -219,6 +219,20 @@ function readUsage(value: unknown): {
219
219
  }
220
220
  }
221
221
 
222
+ function entryTimestampMs(
223
+ entry: { type: 'message'; message: { role: string; [k: string]: unknown } },
224
+ message: { role: string; [k: string]: unknown },
225
+ ): number {
226
+ return timestampMs(readField(entry, 'timestamp')) ?? timestampMs(readField(message, 'timestamp')) ?? 0
227
+ }
228
+
229
+ function timestampMs(value: unknown): number | null {
230
+ if (typeof value === 'number' && Number.isFinite(value)) return value
231
+ if (typeof value !== 'string' || value === '') return null
232
+ const parsed = Date.parse(value)
233
+ return Number.isFinite(parsed) ? parsed : null
234
+ }
235
+
222
236
  function* readThinkingEvents(content: unknown, ts: number): Iterable<InspectEvent> {
223
237
  if (!Array.isArray(content)) return
224
238
  for (const block of content) {
@@ -0,0 +1,377 @@
1
+ export type CodexFetchObserverLogger = {
2
+ info: (msg: string) => void
3
+ warn: (msg: string) => void
4
+ }
5
+
6
+ export type CodexFetchObserverOptions = {
7
+ logger?: CodexFetchObserverLogger
8
+ codexHost?: string
9
+ now?: () => number
10
+ // Override the default pre-headers (TTFB) deadline applied to the outer
11
+ // fetch(). When the codex backend silently holds a request without sending
12
+ // response headers, this is the timer that releases the request so
13
+ // `pi-coding-agent`'s `_isRetryableError` can retry. Default: 15_000 ms.
14
+ //
15
+ // Healthy Codex turns return response headers within ~1s (observed
16
+ // production p50: ~860ms). The first SSE event (`response.created`) is
17
+ // emitted before any model work begins and arrives within ~50ms of
18
+ // headers. Pathological-but-healthy upper bounds: TLS handshake on a cold
19
+ // connection (~2s), prompt-prefill on a cache miss with large input
20
+ // (~3s), Cloudflare PoP routing slowness (~2s) — sum ~7s. 15s is ~2x
21
+ // that, so anything past it is almost certainly the silent-hang failure
22
+ // mode rather than a real request making progress. False-positive cost
23
+ // is one retry (~5s extra); false-negative cost is the full Bun socket
24
+ // deadline (~268s). Aggressive wins.
25
+ ttfbMs?: number
26
+ // Override the sliding inter-chunk idle deadline applied to the SSE body
27
+ // reader. Resets on every chunk; if no bytes arrive within this window the
28
+ // body stream errors. Default: 300_000 ms, matches `openai/codex`'s Rust CLI
29
+ // `DEFAULT_STREAM_IDLE_TIMEOUT_MS`. Set to 0 to disable just this timer.
30
+ idleMs?: number
31
+ // Schedule fn for tests. Receives (delayMs, callback) and returns a handle
32
+ // the wrapper can pass to `clear`. Default: `setTimeout`/`clearTimeout`.
33
+ scheduler?: TimeoutScheduler
34
+ }
35
+
36
+ export type TimeoutScheduler = {
37
+ set: (delayMs: number, cb: () => void) => unknown
38
+ clear: (handle: unknown) => void
39
+ }
40
+
41
+ const DEFAULT_CODEX_HOST = 'chatgpt.com'
42
+ const CODEX_PATH_FRAGMENT = '/codex/responses'
43
+ const ENV_DISABLE_OBSERVER = 'TYPECLAW_CODEX_FETCH_OBSERVER'
44
+ const ENV_DISABLE_TIMEOUTS = 'TYPECLAW_CODEX_TIMEOUTS'
45
+ const ENV_TTFB_MS = 'TYPECLAW_CODEX_TTFB_MS'
46
+ const ENV_IDLE_MS = 'TYPECLAW_CODEX_IDLE_MS'
47
+ const DEFAULT_TTFB_MS = 15_000
48
+ const DEFAULT_IDLE_MS = 300_000
49
+ const LOG_PREFIX = '[codex-fetch]'
50
+
51
+ const defaultScheduler: TimeoutScheduler = {
52
+ set: (delayMs, cb) => setTimeout(cb, delayMs),
53
+ clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
54
+ }
55
+
56
+ const consoleLogger: CodexFetchObserverLogger = {
57
+ info: (m) => console.log(m),
58
+ warn: (m) => console.warn(m),
59
+ }
60
+
61
+ type InstallState = {
62
+ originalFetch: typeof fetch
63
+ uninstall: () => void
64
+ }
65
+
66
+ let installed: InstallState | null = null
67
+
68
+ // Returns true when the request is for the Codex Responses endpoint and we
69
+ // should attach phase-timing instrumentation. Method check matches the
70
+ // pi-ai provider (only POST hits codex/responses); GETs to the same host
71
+ // (auth probes, etc.) are deliberately ignored.
72
+ function shouldObserve(input: RequestInfo | URL, init: RequestInit | undefined, codexHost: string): boolean {
73
+ const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase()
74
+ if (method !== 'POST') return false
75
+ let urlString: string
76
+ if (typeof input === 'string') urlString = input
77
+ else if (input instanceof URL) urlString = input.toString()
78
+ else urlString = input.url
79
+ let parsed: URL
80
+ try {
81
+ parsed = new URL(urlString)
82
+ } catch {
83
+ return false
84
+ }
85
+ if (parsed.hostname !== codexHost) return false
86
+ return parsed.pathname.includes(CODEX_PATH_FRAGMENT)
87
+ }
88
+
89
+ function quote(value: string | null): string {
90
+ if (value === null) return 'null'
91
+ return `"${value.replace(/"/g, '\\"')}"`
92
+ }
93
+
94
+ function formatLine(fields: {
95
+ status: number | null
96
+ headersMs: number | null
97
+ firstByteMs: number | null
98
+ totalMs: number
99
+ bodyBytes: number
100
+ retryAfter: string | null
101
+ requestId: string | null
102
+ error: string | null
103
+ cause: string | null
104
+ }): string {
105
+ return [
106
+ LOG_PREFIX,
107
+ `status=${fields.status === null ? 'null' : fields.status}`,
108
+ `headers_ms=${fields.headersMs === null ? 'null' : fields.headersMs}`,
109
+ `first_byte_ms=${fields.firstByteMs === null ? 'null' : fields.firstByteMs}`,
110
+ `total_ms=${fields.totalMs}`,
111
+ `body_bytes=${fields.bodyBytes}`,
112
+ `retry_after=${fields.retryAfter === null ? 'null' : fields.retryAfter}`,
113
+ `request_id=${fields.requestId === null ? 'null' : fields.requestId}`,
114
+ `error=${quote(fields.error)}`,
115
+ `cause=${fields.cause === null ? 'null' : fields.cause}`,
116
+ ].join(' ')
117
+ }
118
+
119
+ function readEnvMs(name: string, fallback: number): number {
120
+ const raw = process.env[name]
121
+ if (raw === undefined || raw === '') return fallback
122
+ const parsed = Number.parseInt(raw, 10)
123
+ if (!Number.isFinite(parsed) || parsed < 0) return fallback
124
+ return parsed
125
+ }
126
+
127
+ type BodyTapConfig = {
128
+ idleMs: number
129
+ scheduler: TimeoutScheduler
130
+ }
131
+
132
+ function attachBodyTimingTap(
133
+ response: Response,
134
+ start: number,
135
+ headersMs: number,
136
+ status: number,
137
+ retryAfter: string | null,
138
+ requestId: string | null,
139
+ now: () => number,
140
+ logger: CodexFetchObserverLogger,
141
+ config: BodyTapConfig,
142
+ ): Response {
143
+ if (response.body === null) {
144
+ logger.info(
145
+ formatLine({
146
+ status,
147
+ headersMs,
148
+ firstByteMs: null,
149
+ totalMs: now() - start,
150
+ bodyBytes: 0,
151
+ retryAfter,
152
+ requestId,
153
+ error: null,
154
+ cause: null,
155
+ }),
156
+ )
157
+ return response
158
+ }
159
+
160
+ let firstByteMs: number | null = null
161
+ let bodyBytes = 0
162
+ let settled = false
163
+ let cause: string | null = null
164
+
165
+ const settle = (error: string | null) => {
166
+ if (settled) return
167
+ settled = true
168
+ logger.info(
169
+ formatLine({
170
+ status,
171
+ headersMs,
172
+ firstByteMs,
173
+ totalMs: now() - start,
174
+ bodyBytes,
175
+ retryAfter,
176
+ requestId,
177
+ error,
178
+ cause,
179
+ }),
180
+ )
181
+ }
182
+
183
+ const tap = new TransformStream<Uint8Array, Uint8Array>({
184
+ transform(chunk, controller) {
185
+ if (firstByteMs === null) firstByteMs = now() - start
186
+ bodyBytes += chunk.byteLength
187
+ controller.enqueue(chunk)
188
+ },
189
+ flush() {
190
+ settle(null)
191
+ },
192
+ })
193
+
194
+ const piped = response.body.pipeThrough(tap, { preventCancel: false })
195
+
196
+ const idleController = config.idleMs > 0 ? new AbortController() : null
197
+ let idleHandle: unknown = null
198
+ const armIdleTimer = () => {
199
+ if (idleController === null) return
200
+ if (idleHandle !== null) config.scheduler.clear(idleHandle)
201
+ idleHandle = config.scheduler.set(config.idleMs, () => {
202
+ cause = 'idle_timeout'
203
+ idleController.abort(new Error(`Codex SSE body idle for ${config.idleMs}ms (typeclaw observer timeout)`))
204
+ })
205
+ }
206
+ const disarmIdleTimer = () => {
207
+ if (idleHandle !== null) {
208
+ config.scheduler.clear(idleHandle)
209
+ idleHandle = null
210
+ }
211
+ }
212
+
213
+ // The idle abort listener is installed exactly once for the lifetime of the
214
+ // stream and removed in `finally`. Earlier shapes constructed a fresh
215
+ // `Promise.race` listener per chunk; if `reader.read()` won the race, the
216
+ // listener was never removed and closures accumulated on the signal across a
217
+ // long stream. Keeping one shared abort promise bounds the listener count to
218
+ // 1 regardless of chunk count.
219
+ const observerBody = new ReadableStream<Uint8Array>({
220
+ async start(controller) {
221
+ const reader = piped.getReader()
222
+ armIdleTimer()
223
+ let abortFired = false
224
+ let onAbort: (() => void) | null = null
225
+ const abortPromise = idleController
226
+ ? new Promise<never>((_, reject) => {
227
+ onAbort = () => {
228
+ abortFired = true
229
+ reject(idleController.signal.reason ?? new Error('idle timeout'))
230
+ }
231
+ if (idleController.signal.aborted) onAbort()
232
+ else idleController.signal.addEventListener('abort', onAbort, { once: true })
233
+ })
234
+ : null
235
+ // Swallow the shared rejection if no race ever observes it (clean stream
236
+ // end before any timeout). Without this, an aborted-after-close path
237
+ // could surface as an unhandled rejection on the runtime.
238
+ abortPromise?.catch(() => {})
239
+ try {
240
+ while (true) {
241
+ const readPromise = reader.read()
242
+ const result = abortPromise ? await Promise.race([readPromise, abortPromise]) : await readPromise
243
+ if (abortFired) {
244
+ reader.cancel(idleController!.signal.reason).catch(() => {})
245
+ throw idleController!.signal.reason
246
+ }
247
+ const { done, value } = result
248
+ if (done) {
249
+ disarmIdleTimer()
250
+ controller.close()
251
+ return
252
+ }
253
+ armIdleTimer()
254
+ controller.enqueue(value)
255
+ }
256
+ } catch (err) {
257
+ disarmIdleTimer()
258
+ const message = err instanceof Error ? err.message : String(err)
259
+ settle(message)
260
+ controller.error(err)
261
+ } finally {
262
+ if (onAbort !== null && idleController !== null && !idleController.signal.aborted) {
263
+ idleController.signal.removeEventListener('abort', onAbort)
264
+ }
265
+ reader.releaseLock()
266
+ }
267
+ },
268
+ cancel(reason) {
269
+ disarmIdleTimer()
270
+ const message = reason === undefined ? 'cancelled' : reason instanceof Error ? reason.message : String(reason)
271
+ settle(message)
272
+ },
273
+ })
274
+
275
+ return new Response(observerBody, {
276
+ status: response.status,
277
+ statusText: response.statusText,
278
+ headers: response.headers,
279
+ })
280
+ }
281
+
282
+ export function installCodexFetchObserver(opts: CodexFetchObserverOptions = {}): () => void {
283
+ if (process.env[ENV_DISABLE_OBSERVER] === 'off') {
284
+ return () => {}
285
+ }
286
+ const logger = opts.logger ?? consoleLogger
287
+ if (installed !== null) {
288
+ logger.warn(`${LOG_PREFIX} install called but observer already installed; ignoring`)
289
+ return installed.uninstall
290
+ }
291
+
292
+ const codexHost = opts.codexHost ?? DEFAULT_CODEX_HOST
293
+ const now = opts.now ?? Date.now
294
+ const scheduler = opts.scheduler ?? defaultScheduler
295
+ const timeoutsEnabled = process.env[ENV_DISABLE_TIMEOUTS] !== 'off'
296
+ const ttfbMs = timeoutsEnabled ? (opts.ttfbMs ?? readEnvMs(ENV_TTFB_MS, DEFAULT_TTFB_MS)) : 0
297
+ const idleMs = timeoutsEnabled ? (opts.idleMs ?? readEnvMs(ENV_IDLE_MS, DEFAULT_IDLE_MS)) : 0
298
+ const originalFetch = globalThis.fetch
299
+
300
+ const wrappedImpl = async (
301
+ input: Parameters<typeof fetch>[0],
302
+ init?: Parameters<typeof fetch>[1],
303
+ ): Promise<Response> => {
304
+ if (!shouldObserve(input, init, codexHost)) {
305
+ return originalFetch(input, init)
306
+ }
307
+ const start = now()
308
+
309
+ let ttfbCause: 'ttfb_timeout' | null = null
310
+ let ttfbHandle: unknown = null
311
+ let initWithSignal: RequestInit | undefined = init
312
+ if (ttfbMs > 0) {
313
+ const ttfbController = new AbortController()
314
+ ttfbHandle = scheduler.set(ttfbMs, () => {
315
+ ttfbCause = 'ttfb_timeout'
316
+ ttfbController.abort(
317
+ new Error(`Codex fetch timed out before response headers after ${ttfbMs}ms (typeclaw observer timeout)`),
318
+ )
319
+ })
320
+ const signal = init?.signal ? AbortSignal.any([init.signal, ttfbController.signal]) : ttfbController.signal
321
+ initWithSignal = { ...init, signal }
322
+ }
323
+
324
+ let response: Response
325
+ try {
326
+ response = await originalFetch(input, initWithSignal)
327
+ } catch (err) {
328
+ if (ttfbHandle !== null) scheduler.clear(ttfbHandle)
329
+ const isTtfbAbort = ttfbCause === 'ttfb_timeout'
330
+ const surfacedError = isTtfbAbort
331
+ ? new Error(`Codex fetch timed out before response headers after ${ttfbMs}ms (typeclaw observer timeout)`)
332
+ : err
333
+ const message = surfacedError instanceof Error ? surfacedError.message : String(surfacedError)
334
+ logger.info(
335
+ formatLine({
336
+ status: null,
337
+ headersMs: null,
338
+ firstByteMs: null,
339
+ totalMs: now() - start,
340
+ bodyBytes: 0,
341
+ retryAfter: null,
342
+ requestId: null,
343
+ error: message,
344
+ cause: ttfbCause,
345
+ }),
346
+ )
347
+ throw surfacedError
348
+ }
349
+ if (ttfbHandle !== null) scheduler.clear(ttfbHandle)
350
+ const headersMs = now() - start
351
+ const retryAfter = response.headers.get('retry-after')
352
+ const requestId = response.headers.get('x-request-id')
353
+ return attachBodyTimingTap(response, start, headersMs, response.status, retryAfter, requestId, now, logger, {
354
+ idleMs,
355
+ scheduler,
356
+ })
357
+ }
358
+
359
+ // Preserve any static methods Bun attaches to `globalThis.fetch` (e.g.
360
+ // `preconnect`) so the wrapper is a drop-in replacement.
361
+ const wrapped = Object.assign(wrappedImpl, {
362
+ preconnect: (originalFetch as { preconnect?: (url: string) => void }).preconnect ?? (() => {}),
363
+ }) as typeof fetch
364
+
365
+ globalThis.fetch = wrapped
366
+
367
+ const uninstall = () => {
368
+ if (installed === null) return
369
+ if (globalThis.fetch === wrapped) {
370
+ globalThis.fetch = originalFetch
371
+ }
372
+ installed = null
373
+ }
374
+
375
+ installed = { originalFetch, uninstall }
376
+ return uninstall
377
+ }
package/src/run/index.ts CHANGED
@@ -59,11 +59,12 @@ import { createTunnelManager, type TunnelManager, type TunnelManagerOptions } fr
59
59
 
60
60
  import { BUNDLED_PLUGINS } from './bundled-plugins'
61
61
  import { buildChannelSessionFactory } from './channel-session-factory'
62
+ import { installCodexFetchObserver } from './codex-fetch-observer'
62
63
  import { createPluginRuntime, type PluginRuntime, type PluginSubagentEntry } from './plugin-runtime'
63
64
 
64
65
  type BunServer = ReturnType<Server['start']>
65
66
 
66
- export type TuiFactory = (options: TuiOptions) => { run: () => Promise<void> }
67
+ export type TuiFactory = (options: TuiOptions) => { run: () => Promise<unknown> }
67
68
 
68
69
  export type LoadCronFn = (agentDir: string, options?: { subagents?: SubagentRegistry }) => Promise<LoadCronResult>
69
70
  export type SchedulerFactory = (options: { cwd: string; file: CronFile; onFire: (job: CronJob) => void }) => Scheduler
@@ -86,7 +87,7 @@ export type StartAgentOptions = {
86
87
 
87
88
  export type StartAgentResult = {
88
89
  server: BunServer
89
- tuiPromise: Promise<void> | null
90
+ tuiPromise: Promise<unknown> | null
90
91
  scheduler: Scheduler | null
91
92
  cronConsumer: CronConsumer | null
92
93
  subagentConsumer: SubagentConsumer
@@ -113,6 +114,14 @@ export async function startAgent({
113
114
  }: StartAgentOptions): Promise<StartAgentResult> {
114
115
  const reloadRegistry = new ReloadRegistry()
115
116
 
117
+ // Wrap globalThis.fetch BEFORE any plugin/session/manager construction so
118
+ // every Codex Responses call from anywhere in the container is observed.
119
+ // Logs one `[codex-fetch]` line per matched request with phase timings;
120
+ // never aborts, never retries — purely passive instrumentation while we
121
+ // investigate the recurring multi-minute Codex stalls (see issue #394).
122
+ // Opt out with TYPECLAW_CODEX_FETCH_OBSERVER=off.
123
+ const uninstallCodexFetchObserver = installCodexFetchObserver()
124
+
116
125
  // The host CLI sets TYPECLAW_CONTAINER_NAME when it `docker run`s us. When
117
126
  // running outside a typeclaw container (tests, ad-hoc `bun run typeclaw run`
118
127
  // outside docker), the env var is absent and the `restart` tool is omitted —
@@ -329,6 +338,7 @@ export async function startAgent({
329
338
  signal: abortController.signal,
330
339
  runtimeVersion: runtimeVersionOpt.runtimeVersion,
331
340
  containerName: containerNameOpt.containerName,
341
+ sessionFactory,
332
342
  }),
333
343
  subagent: (subName: string, payload?: unknown) =>
334
344
  dispatchSpawnSubagent(subName, payload, {
@@ -542,6 +552,7 @@ export async function startAgent({
542
552
  runtimeVersion: CLI_VERSION,
543
553
  containerName,
544
554
  outbound,
555
+ sessionFactory,
545
556
  })
546
557
 
547
558
  const server = createServer({
@@ -585,6 +596,7 @@ export async function startAgent({
585
596
  subagentCompletionBridge.stop()
586
597
  await tunnelManager.stop()
587
598
  await channelManager.stop()
599
+ uninstallCodexFetchObserver()
588
600
  }
589
601
 
590
602
  if (!attachTui) {
@@ -1,4 +1,9 @@
1
- import { createSessionWithDispose, type SessionOrigin } from '@/agent'
1
+ import {
2
+ createSessionWithDispose,
3
+ type CreateSessionOptions,
4
+ type CreateSessionResult,
5
+ type SessionOrigin,
6
+ } from '@/agent'
2
7
  import type { PermissionService } from '@/permissions'
3
8
  import type {
4
9
  CommandExecResult,
@@ -11,6 +16,7 @@ import type {
11
16
  SpawnSubagentOptions,
12
17
  } from '@/plugin'
13
18
  import type { PluginRuntime } from '@/run/plugin-runtime'
19
+ import type { SessionFactory } from '@/sessions'
14
20
 
15
21
  export type CommandSpawnSubagent = (name: string, payload?: unknown, options?: SpawnSubagentOptions) => Promise<void>
16
22
 
@@ -29,6 +35,14 @@ export type CommandRunnerOptions = {
29
35
  runtimeVersion: string | undefined
30
36
  containerName: string | undefined
31
37
  outbound: CommandOutbound
38
+ // Hands a persisted SessionManager to every prompt session spawned from a
39
+ // plugin command's `ctx.prompt`. Required so the session writes its JSONL
40
+ // (and therefore its `message.usage`) under sessions/, which is what
41
+ // `typeclaw usage` and the `bundled-plugins/backup` plugin scan. Without
42
+ // this every plugin-command LLM call would fall through to
43
+ // `SessionManager.inMemory()` and never persist usage — see
44
+ // `runPromptForCommand` below.
45
+ sessionFactory: SessionFactory
32
46
  }
33
47
 
34
48
  type CommandHandle = {
@@ -166,6 +180,7 @@ export function createCommandRunner(opts: CommandRunnerOptions): CommandRunner {
166
180
  containerName: opts.containerName,
167
181
  permissions: opts.permissions,
168
182
  signal: abortController.signal,
183
+ sessionFactory: opts.sessionFactory,
169
184
  }),
170
185
  subagent: (subName, payload) =>
171
186
  opts.spawnSubagent(subName, payload, {
@@ -331,6 +346,8 @@ function writeLine(stream: WritableStream<Uint8Array>, line: string): void {
331
346
  void writer.write(new TextEncoder().encode(`${line}\n`)).then(() => writer.releaseLock())
332
347
  }
333
348
 
349
+ export type CreateSessionForCommand = (options: CreateSessionOptions) => Promise<CreateSessionResult>
350
+
334
351
  export async function runPromptForCommand(args: {
335
352
  text: string
336
353
  origin: SessionOrigin
@@ -340,6 +357,16 @@ export async function runPromptForCommand(args: {
340
357
  containerName: string | undefined
341
358
  permissions: PermissionService
342
359
  signal: AbortSignal
360
+ // Persisted-session source. Each call gets a fresh SessionManager so the
361
+ // resulting JSONL is its own file under sessions/ — the same shape the
362
+ // cron `prompt` path uses in src/run/index.ts. Passing in-memory here
363
+ // regresses `typeclaw usage` (see CommandRunnerOptions.sessionFactory).
364
+ sessionFactory: SessionFactory
365
+ // Test seam for the agent-session boundary. Production passes the real
366
+ // `createSessionWithDispose`; tests inject a fake to verify wiring
367
+ // (specifically: the sessionManager handed off must be persisted, not
368
+ // in-memory) without booting the full session stack.
369
+ _createSession?: CreateSessionForCommand
343
370
  }): Promise<string> {
344
371
  // Mirrors src/agent/multimodal/look-at.ts: spawn a session, prompt, capture
345
372
  // the final assistant text, dispose. Unlike look-at we want the FULL agent
@@ -349,9 +376,11 @@ export async function runPromptForCommand(args: {
349
376
  // loader (no `systemPromptOverride`).
350
377
  const snapshot = args.runtime.get()
351
378
  const sessionId = resolveSessionIdForOrigin(args.origin)
352
- const { session, dispose } = await createSessionWithDispose({
379
+ const create = args._createSession ?? createSessionWithDispose
380
+ const { session, dispose } = await create({
353
381
  origin: args.origin,
354
382
  permissions: args.permissions,
383
+ sessionManager: args.sessionFactory.createPersisted(),
355
384
  plugins: {
356
385
  registry: snapshot.registry,
357
386
  hooks: snapshot.hooks,