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.
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/agent/index.ts +37 -4
- package/src/agent/multimodal/look-at.ts +8 -0
- package/src/agent/restart-handoff/index.ts +91 -0
- package/src/agent/restart-handoff/paths.ts +11 -0
- package/src/agent/session-origin.ts +30 -10
- package/src/agent/subagent-completion-reminder.ts +4 -2
- package/src/agent/system-prompt.ts +3 -1
- package/src/agent/tools/restart.ts +42 -1
- package/src/agent/tools/skip-response.ts +157 -0
- package/src/bundled-plugins/memory/README.md +18 -2
- package/src/bundled-plugins/memory/index.ts +108 -6
- package/src/bundled-plugins/memory/memory-logger.ts +33 -24
- package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
- package/src/channels/adapters/discord-bot-invite.ts +89 -0
- package/src/channels/adapters/github/auth-app.ts +53 -9
- package/src/channels/adapters/github/auth-pat.ts +4 -1
- package/src/channels/adapters/github/auth.ts +10 -0
- package/src/channels/adapters/github/event-permissions.ts +83 -0
- package/src/channels/adapters/github/inbound.ts +126 -1
- package/src/channels/adapters/github/index.ts +60 -66
- package/src/channels/adapters/github/outbound.ts +65 -17
- package/src/channels/adapters/github/permission-guidance.ts +169 -0
- package/src/channels/adapters/github/team-membership.ts +56 -0
- package/src/channels/adapters/kakaotalk-classify.ts +13 -1
- package/src/channels/adapters/kakaotalk.ts +2 -0
- package/src/channels/router.ts +269 -34
- package/src/channels/schema.ts +8 -7
- package/src/channels/types.ts +1 -1
- package/src/cli/channel.ts +138 -52
- package/src/cli/init.ts +139 -100
- package/src/cli/inspect-controller.ts +66 -0
- package/src/cli/inspect.ts +24 -32
- package/src/cli/prompt-pem.ts +113 -0
- package/src/cli/run.ts +24 -5
- package/src/cli/tui.ts +34 -10
- package/src/cli/tunnel.ts +453 -14
- package/src/cli/ui.ts +22 -0
- package/src/compose/discover.ts +5 -0
- package/src/config/config.ts +35 -7
- package/src/config/providers.ts +64 -56
- package/src/init/env-file.ts +66 -0
- package/src/init/hatching.ts +32 -5
- package/src/init/index.ts +131 -39
- package/src/init/validate-api-key.ts +31 -0
- package/src/inspect/index.ts +5 -1
- package/src/inspect/loop.ts +12 -1
- package/src/inspect/replay.ts +15 -1
- package/src/run/codex-fetch-observer.ts +377 -0
- package/src/run/index.ts +14 -2
- package/src/server/command-runner.ts +31 -2
- package/src/server/index.ts +59 -1
- package/src/shared/protocol.ts +1 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +47 -1
- package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
- package/src/tui/index.ts +17 -5
- package/src/tunnels/index.ts +1 -0
- package/src/tunnels/manager.ts +18 -0
- package/src/tunnels/providers/cloudflare-named.ts +224 -0
- package/src/tunnels/types.ts +17 -1
- 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
|
package/src/inspect/index.ts
CHANGED
|
@@ -37,7 +37,11 @@ export type RunInspectOptions = {
|
|
|
37
37
|
liveHint?: string
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export type
|
|
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
|
package/src/inspect/loop.ts
CHANGED
|
@@ -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
|
|
package/src/inspect/replay.ts
CHANGED
|
@@ -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 =
|
|
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<
|
|
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<
|
|
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 {
|
|
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
|
|
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,
|