typeclaw 0.1.3 → 0.1.5
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 +1 -1
- package/package.json +1 -1
- package/src/bundled-plugins/memory/README.md +8 -8
- package/src/bundled-plugins/memory/dreaming.ts +117 -1
- package/src/cli/init.ts +35 -6
- package/src/cli/reload.ts +6 -3
- package/src/cli/tui.ts +6 -3
- package/src/config/config.ts +162 -17
- package/src/config/index.ts +8 -1
- package/src/container/index.ts +3 -1
- package/src/container/port.ts +10 -0
- package/src/container/start.ts +54 -10
- package/src/doctor/checks.ts +8 -28
- package/src/doctor/commit.ts +44 -3
- package/src/doctor/plugin-bridge.ts +46 -3
- package/src/init/dockerfile.ts +62 -4
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +31 -24
- package/src/reload/client.ts +25 -1
- package/src/run/index.ts +13 -1
- package/src/secrets/storage.ts +15 -0
- package/src/server/index.ts +80 -64
- package/src/skills/typeclaw-config/SKILL.md +70 -52
- package/src/skills/typeclaw-memory/SKILL.md +8 -8
- package/src/tui/client.ts +66 -5
- package/src/tui/index.ts +61 -9
- package/typeclaw.schema.json +91 -54
package/src/init/index.ts
CHANGED
|
@@ -3,10 +3,16 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
|
3
3
|
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
|
|
6
|
-
import { config, configSchema, type Config } from '@/config'
|
|
7
|
-
import {
|
|
6
|
+
import { config, configSchema, migrateLegacyConfigShape, type Config } from '@/config'
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_MODEL_REF,
|
|
9
|
+
KNOWN_PROVIDERS,
|
|
10
|
+
providerForModelRef,
|
|
11
|
+
type KnownModelRef,
|
|
12
|
+
type KnownProviderId,
|
|
13
|
+
} from '@/config/providers'
|
|
8
14
|
import { checkDockerAvailable, type DockerAvailability, type DockerExec, start } from '@/container'
|
|
9
|
-
import { type Channels, type Secret, SecretsBackend } from '@/secrets'
|
|
15
|
+
import { createSecretsStoreForAgent, type Channels, type Secret, SecretsBackend } from '@/secrets'
|
|
10
16
|
import { createTui } from '@/tui'
|
|
11
17
|
|
|
12
18
|
import { resolveBaseImageVersion, resolveScaffoldVersion } from './cli-version'
|
|
@@ -23,7 +29,6 @@ export { GITKEEP_FILE, PACKAGES_DIR } from './paths'
|
|
|
23
29
|
|
|
24
30
|
const CONFIG_FILE = 'typeclaw.json'
|
|
25
31
|
const CRON_FILE = 'cron.json'
|
|
26
|
-
const SECRETS_FILE = '.env'
|
|
27
32
|
const PACKAGE_FILE = 'package.json'
|
|
28
33
|
|
|
29
34
|
const MARKDOWN_FILES = ['AGENTS.md', 'IDENTITY.md', 'SOUL.md', 'USER.md'] as const
|
|
@@ -269,10 +274,10 @@ export async function defaultRunHatching({
|
|
|
269
274
|
// the preferred port, otherwise we'd connect to the wrong service.
|
|
270
275
|
const hostPort = launch.hostPort
|
|
271
276
|
|
|
272
|
-
await waitForAgentFn(`http://
|
|
277
|
+
await waitForAgentFn(`http://127.0.0.1:${hostPort}`, { timeoutMs: 30_000 })
|
|
273
278
|
|
|
274
279
|
const tui = tuiFactory({
|
|
275
|
-
url:
|
|
280
|
+
url: buildTuiUrl(hostPort, launch.tuiToken),
|
|
276
281
|
initialPrompt: HATCHING_PROMPT,
|
|
277
282
|
})
|
|
278
283
|
await tui.run()
|
|
@@ -282,6 +287,12 @@ export async function defaultRunHatching({
|
|
|
282
287
|
}
|
|
283
288
|
}
|
|
284
289
|
|
|
290
|
+
function buildTuiUrl(hostPort: number, token: string | null): string {
|
|
291
|
+
const url = new URL(`ws://127.0.0.1:${hostPort}`)
|
|
292
|
+
if (token !== null) url.searchParams.set('token', token)
|
|
293
|
+
return url.toString()
|
|
294
|
+
}
|
|
295
|
+
|
|
285
296
|
// Probe the server's plain HTTP fallback (non-upgrade requests get a 200 with
|
|
286
297
|
// body "typeclaw agent") instead of opening a WebSocket. Opening a WS here
|
|
287
298
|
// would trigger createSession on the server and burn an LLM session just to
|
|
@@ -484,7 +495,7 @@ export async function writeDockerAssets(root: string): Promise<DockerAssetsResul
|
|
|
484
495
|
const typeclawConfig = await readTypeclawConfig(root)
|
|
485
496
|
await writeFile(
|
|
486
497
|
join(root, DOCKERFILE),
|
|
487
|
-
buildDockerfile(typeclawConfig.
|
|
498
|
+
buildDockerfile(typeclawConfig.docker.file, { baseImageVersion: resolveBaseImageVersion(root) }),
|
|
488
499
|
{ flag: 'wx' },
|
|
489
500
|
).catch(ignoreExists)
|
|
490
501
|
|
|
@@ -502,7 +513,7 @@ async function readPackageJson(root: string): Promise<{ name?: string; dependenc
|
|
|
502
513
|
async function readTypeclawConfig(root: string): Promise<Config> {
|
|
503
514
|
try {
|
|
504
515
|
const raw = await readFile(join(root, CONFIG_FILE), 'utf8')
|
|
505
|
-
return configSchema.parse(JSON.parse(raw))
|
|
516
|
+
return configSchema.parse(migrateLegacyConfigShape(JSON.parse(raw)).json)
|
|
506
517
|
} catch (error) {
|
|
507
518
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return configSchema.parse({})
|
|
508
519
|
throw error
|
|
@@ -558,15 +569,10 @@ export async function initGitRepo(cwd: string): Promise<GitInitResult> {
|
|
|
558
569
|
}
|
|
559
570
|
}
|
|
560
571
|
|
|
561
|
-
// Writes
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
// reads the value at runtime via `setRuntimeApiKey` and never persists it to
|
|
566
|
-
// `secrets.json`, see `src/agent/auth.ts`); channel tokens skip the .env hop
|
|
567
|
-
// entirely and land in `secrets.json#channels` as `{ value }` Secrets that
|
|
568
|
-
// `hydrateChannelEnvFromSecrets` injects into `process.env` only when the
|
|
569
|
-
// canonical env var is unset, see `src/secrets/hydrate.ts`.
|
|
572
|
+
// Writes LLM provider API keys to `secrets.json#providers` and channel adapter
|
|
573
|
+
// tokens to `secrets.json#channels`. Both paths go through the structured
|
|
574
|
+
// v2 secrets envelope so reruns can reuse existing values without depending on
|
|
575
|
+
// host-stage env files.
|
|
570
576
|
export async function writeSecrets(
|
|
571
577
|
root: string,
|
|
572
578
|
{
|
|
@@ -578,9 +584,7 @@ export async function writeSecrets(
|
|
|
578
584
|
telegramBotToken,
|
|
579
585
|
}: {
|
|
580
586
|
model?: KnownModelRef
|
|
581
|
-
// Omitted on the OAuth path — credentials live in secrets.json
|
|
582
|
-
// The .env file still gets written (empty) so post-init callers that
|
|
583
|
-
// read it don't ENOENT-crash.
|
|
587
|
+
// Omitted on the OAuth path — credentials live in secrets.json via the OAuth runner.
|
|
584
588
|
apiKey?: string
|
|
585
589
|
discordBotToken?: string
|
|
586
590
|
slackBotToken?: string
|
|
@@ -590,12 +594,9 @@ export async function writeSecrets(
|
|
|
590
594
|
): Promise<void> {
|
|
591
595
|
const providerId = providerForModelRef(model)
|
|
592
596
|
const apiKeyEnv = KNOWN_PROVIDERS[providerId].apiKeyEnv
|
|
593
|
-
const lines: string[] = []
|
|
594
597
|
if (apiKey !== undefined && apiKeyEnv !== null) {
|
|
595
|
-
|
|
598
|
+
createSecretsStoreForAgent(join(root, 'secrets.json')).set(providerId, { type: 'api_key', key: apiKey })
|
|
596
599
|
}
|
|
597
|
-
const body = lines.length > 0 ? `${lines.join('\n')}\n` : ''
|
|
598
|
-
await writeFile(join(root, SECRETS_FILE), body)
|
|
599
600
|
|
|
600
601
|
const channelTokens: Record<string, Record<string, Secret>> = {}
|
|
601
602
|
if (discordBotToken !== undefined && discordBotToken !== '') {
|
|
@@ -623,6 +624,12 @@ export async function writeSecrets(
|
|
|
623
624
|
backend.writeChannelsSync(merged)
|
|
624
625
|
}
|
|
625
626
|
|
|
627
|
+
export async function readExistingProviderApiKey(root: string, providerId: KnownProviderId): Promise<string | null> {
|
|
628
|
+
const provider = KNOWN_PROVIDERS[providerId]
|
|
629
|
+
if (provider.apiKeyEnv === null) return null
|
|
630
|
+
return new SecretsBackend(join(root, 'secrets.json')).tryReadProviderApiKeySync(providerId)
|
|
631
|
+
}
|
|
632
|
+
|
|
626
633
|
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
627
634
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
628
635
|
}
|
package/src/reload/client.ts
CHANGED
|
@@ -16,22 +16,36 @@ export async function requestReload({
|
|
|
16
16
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
17
17
|
}: RequestReloadOptions): Promise<ReloadResult[]> {
|
|
18
18
|
const ws = new WebSocket(url)
|
|
19
|
+
const displayUrl = redactUrl(url)
|
|
19
20
|
|
|
20
21
|
await new Promise<void>((resolve, reject) => {
|
|
22
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
21
23
|
const onOpen = () => {
|
|
22
24
|
cleanup()
|
|
23
25
|
resolve()
|
|
24
26
|
}
|
|
25
27
|
const onError = (err: unknown) => {
|
|
26
28
|
cleanup()
|
|
27
|
-
reject(
|
|
29
|
+
reject(new Error(`failed to connect to ${displayUrl}: ${err instanceof Error ? err.message : String(err)}`))
|
|
30
|
+
}
|
|
31
|
+
const onClose = () => {
|
|
32
|
+
cleanup()
|
|
33
|
+
reject(new Error(`connection to ${displayUrl} closed before opening`))
|
|
28
34
|
}
|
|
29
35
|
const cleanup = () => {
|
|
36
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
30
37
|
ws.removeEventListener('open', onOpen)
|
|
31
38
|
ws.removeEventListener('error', onError)
|
|
39
|
+
ws.removeEventListener('close', onClose)
|
|
32
40
|
}
|
|
41
|
+
timer = setTimeout(() => {
|
|
42
|
+
cleanup()
|
|
43
|
+
ws.close()
|
|
44
|
+
reject(new Error(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
|
|
45
|
+
}, timeoutMs)
|
|
33
46
|
ws.addEventListener('open', onOpen, { once: true })
|
|
34
47
|
ws.addEventListener('error', onError, { once: true })
|
|
48
|
+
ws.addEventListener('close', onClose, { once: true })
|
|
35
49
|
})
|
|
36
50
|
|
|
37
51
|
try {
|
|
@@ -57,3 +71,13 @@ export async function requestReload({
|
|
|
57
71
|
ws.close()
|
|
58
72
|
}
|
|
59
73
|
}
|
|
74
|
+
|
|
75
|
+
function redactUrl(url: string): string {
|
|
76
|
+
try {
|
|
77
|
+
const parsed = new URL(url)
|
|
78
|
+
if (parsed.searchParams.has('token')) parsed.searchParams.set('token', '<redacted>')
|
|
79
|
+
return parsed.toString()
|
|
80
|
+
} catch {
|
|
81
|
+
return url
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/run/index.ts
CHANGED
|
@@ -87,6 +87,8 @@ export async function startAgent({
|
|
|
87
87
|
// which is what we want, since there is no host daemon to honor it anyway.
|
|
88
88
|
const containerName = process.env.TYPECLAW_CONTAINER_NAME
|
|
89
89
|
const containerNameOpt = containerName !== undefined ? { containerName } : {}
|
|
90
|
+
const tuiToken = process.env.TYPECLAW_TUI_TOKEN
|
|
91
|
+
const tuiTokenOpt = tuiToken !== undefined && tuiToken !== '' ? { tuiToken } : {}
|
|
90
92
|
reloadRegistry.register(createConfigReloadable({ cwd }))
|
|
91
93
|
|
|
92
94
|
const pluginConfigsByName = loadPluginConfigsSync(cwd)
|
|
@@ -312,6 +314,7 @@ export async function startAgent({
|
|
|
312
314
|
agentDir: cwd,
|
|
313
315
|
pluginRuntime,
|
|
314
316
|
...containerNameOpt,
|
|
317
|
+
...tuiTokenOpt,
|
|
315
318
|
...containerBrokerOpt,
|
|
316
319
|
}).start()
|
|
317
320
|
|
|
@@ -343,7 +346,9 @@ export async function startAgent({
|
|
|
343
346
|
}
|
|
344
347
|
}
|
|
345
348
|
|
|
346
|
-
const
|
|
349
|
+
const serverPort = server.port
|
|
350
|
+
if (serverPort === undefined) throw new Error('server did not report a listening port')
|
|
351
|
+
const url = buildLocalTuiUrl(serverPort, tuiTokenOpt.tuiToken ?? null)
|
|
347
352
|
const tui = createTui({ url, initialPrompt })
|
|
348
353
|
const tuiPromise = tui.run()
|
|
349
354
|
return {
|
|
@@ -361,6 +366,13 @@ export async function startAgent({
|
|
|
361
366
|
}
|
|
362
367
|
}
|
|
363
368
|
|
|
369
|
+
function buildLocalTuiUrl(port: number, token: string | null): string {
|
|
370
|
+
if (token === null) return `ws://localhost:${port}`
|
|
371
|
+
const url = new URL(`ws://localhost:${port}`)
|
|
372
|
+
url.searchParams.set('token', token)
|
|
373
|
+
return url.toString()
|
|
374
|
+
}
|
|
375
|
+
|
|
364
376
|
async function disposeMaterializedSkills(pluginRuntime: PluginRuntime): Promise<void> {
|
|
365
377
|
const pending = pluginRuntime.drainPendingDisposal()
|
|
366
378
|
const current = pluginRuntime.get().materializedSkills
|
package/src/secrets/storage.ts
CHANGED
|
@@ -160,6 +160,21 @@ export class SecretsBackend implements AuthStorageBackend {
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
tryReadProviderApiKeySync(providerId: string, env: NodeJS.ProcessEnv = process.env): string | null {
|
|
164
|
+
if (!existsSync(this.secretsPath)) return null
|
|
165
|
+
let release: (() => void) | undefined
|
|
166
|
+
try {
|
|
167
|
+
release = this.acquireSyncLockWithRetry()
|
|
168
|
+
const credential = this.readEnvelope().providers[providerId]
|
|
169
|
+
if (credential?.type !== 'api_key') return null
|
|
170
|
+
const resolved =
|
|
171
|
+
resolveSecret(credential.key, providerKeyDefaultEnv(providerId), env) ?? credential.key.value ?? ''
|
|
172
|
+
return resolved.trim() !== '' ? resolved : null
|
|
173
|
+
} finally {
|
|
174
|
+
release?.()
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
163
178
|
writeChannelsSync(next: Channels): void {
|
|
164
179
|
this.ensureParentDir()
|
|
165
180
|
this.ensureFileExists()
|
package/src/server/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ export type ServerOptions = {
|
|
|
31
31
|
agentDir?: string
|
|
32
32
|
pluginRuntime?: PluginRuntime
|
|
33
33
|
containerName?: string
|
|
34
|
+
tuiToken?: string
|
|
34
35
|
// Optional in-process portbroker handler. When provided, requests to the
|
|
35
36
|
// /portbroker WS path are routed to it instead of being treated as TUI
|
|
36
37
|
// sessions. Omit to keep TUI-only behavior (used by tests + non-container
|
|
@@ -82,6 +83,7 @@ export function createServer({
|
|
|
82
83
|
agentDir,
|
|
83
84
|
pluginRuntime,
|
|
84
85
|
containerName,
|
|
86
|
+
tuiToken,
|
|
85
87
|
containerBroker,
|
|
86
88
|
}: ServerOptions) {
|
|
87
89
|
const sessionStates = new WeakMap<Ws, SessionState>()
|
|
@@ -97,6 +99,9 @@ export function createServer({
|
|
|
97
99
|
if (server.upgrade(req, { data })) return
|
|
98
100
|
return new Response('upgrade failed', { status: 400 })
|
|
99
101
|
}
|
|
102
|
+
if (isWebSocketUpgrade(req) && tuiToken !== undefined && url.searchParams.get('token') !== tuiToken) {
|
|
103
|
+
return new Response('unauthorized', { status: 401 })
|
|
104
|
+
}
|
|
100
105
|
const sessionId = crypto.randomUUID()
|
|
101
106
|
const data: TuiWsData = { kind: 'tui', sessionId }
|
|
102
107
|
if (server.upgrade(req, { data })) return
|
|
@@ -109,73 +114,80 @@ export function createServer({
|
|
|
109
114
|
return
|
|
110
115
|
}
|
|
111
116
|
const ws = rawWs as Ws
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
117
|
+
try {
|
|
118
|
+
const sessionManager = sessionFactory?.createPersisted()
|
|
119
|
+
const sessionFileId = sessionManager?.getSessionId() ?? ws.data.sessionId
|
|
120
|
+
// Snapshot the runtime once so the entire session lifecycle for this
|
|
121
|
+
// ws connection sees one consistent generation of registry+hooks. A
|
|
122
|
+
// reload landing mid-connection swaps the live pointer; this session
|
|
123
|
+
// keeps using the snapshot it was created with until close.
|
|
124
|
+
const runtimeSnapshot = pluginRuntime?.get()
|
|
125
|
+
const pluginsWiring =
|
|
126
|
+
runtimeSnapshot !== undefined && agentDir !== undefined
|
|
127
|
+
? {
|
|
128
|
+
registry: runtimeSnapshot.registry,
|
|
129
|
+
hooks: runtimeSnapshot.hooks,
|
|
130
|
+
sessionId: sessionFileId,
|
|
131
|
+
agentDir,
|
|
132
|
+
}
|
|
133
|
+
: undefined
|
|
134
|
+
const origin: SessionOrigin = { kind: 'tui', sessionId: sessionFileId }
|
|
135
|
+
const result = await createSession({
|
|
136
|
+
reloadRegistry,
|
|
137
|
+
sessionManager,
|
|
138
|
+
origin,
|
|
139
|
+
...(stream ? { stream } : {}),
|
|
140
|
+
...(channelRouter ? { channelRouter } : {}),
|
|
141
|
+
...(pluginsWiring ? { plugins: pluginsWiring } : {}),
|
|
142
|
+
...(containerName !== undefined ? { containerName } : {}),
|
|
143
|
+
})
|
|
144
|
+
const session = 'session' in result ? result.session : result
|
|
145
|
+
const dispose = 'session' in result && result.dispose ? result.dispose : async () => {}
|
|
146
|
+
|
|
147
|
+
const state: SessionState = {
|
|
148
|
+
session,
|
|
149
|
+
sessionFileId,
|
|
150
|
+
origin,
|
|
151
|
+
sessionManager,
|
|
152
|
+
drainQueue: [],
|
|
153
|
+
draining: false,
|
|
154
|
+
unsubBroadcast: null,
|
|
155
|
+
unsubPrompts: null,
|
|
156
|
+
runtimeSnapshot: runtimeSnapshot ?? null,
|
|
157
|
+
dispose,
|
|
158
|
+
}
|
|
159
|
+
sessionStates.set(ws, state)
|
|
154
160
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
161
|
+
if (runtimeSnapshot !== undefined && agentDir !== undefined) {
|
|
162
|
+
await runtimeSnapshot.hooks.runSessionStart({ sessionId: sessionFileId, agentDir })
|
|
163
|
+
}
|
|
158
164
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (stream) {
|
|
162
|
-
state.unsubPrompts = stream.subscribe({ target: { kind: 'session', sessionId: sessionFileId } }, (msg) =>
|
|
163
|
-
enqueuePrompt(ws, state, msg, agentDir),
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
state.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
|
|
167
|
-
const payload: ServerMessage = {
|
|
168
|
-
type: 'notification',
|
|
169
|
-
payload: msg.payload,
|
|
170
|
-
...(msg.replyTo !== undefined ? { replyTo: msg.replyTo } : {}),
|
|
171
|
-
...(msg.meta !== undefined ? { meta: msg.meta } : {}),
|
|
172
|
-
}
|
|
173
|
-
send(ws, payload)
|
|
174
|
-
})
|
|
175
|
-
}
|
|
165
|
+
forwardSessionEvents(ws, session)
|
|
176
166
|
|
|
177
|
-
|
|
178
|
-
|
|
167
|
+
if (stream) {
|
|
168
|
+
state.unsubPrompts = stream.subscribe({ target: { kind: 'session', sessionId: sessionFileId } }, (msg) =>
|
|
169
|
+
enqueuePrompt(ws, state, msg, agentDir),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
state.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
|
|
173
|
+
const payload: ServerMessage = {
|
|
174
|
+
type: 'notification',
|
|
175
|
+
payload: msg.payload,
|
|
176
|
+
...(msg.replyTo !== undefined ? { replyTo: msg.replyTo } : {}),
|
|
177
|
+
...(msg.meta !== undefined ? { meta: msg.meta } : {}),
|
|
178
|
+
}
|
|
179
|
+
send(ws, payload)
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
send(ws, { type: 'connected', sessionId: sessionFileId })
|
|
184
|
+
console.log(`session ${sessionFileId}: open`)
|
|
185
|
+
} catch (err) {
|
|
186
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
187
|
+
console.error(`session ${ws.data.sessionId}: open failed: ${message}`)
|
|
188
|
+
send(ws, { type: 'error', message })
|
|
189
|
+
ws.close()
|
|
190
|
+
}
|
|
179
191
|
},
|
|
180
192
|
async message(rawWs, raw) {
|
|
181
193
|
if (rawWs.data.kind === 'portbroker') {
|
|
@@ -289,6 +301,10 @@ export function createServer({
|
|
|
289
301
|
return { start }
|
|
290
302
|
}
|
|
291
303
|
|
|
304
|
+
function isWebSocketUpgrade(req: Request): boolean {
|
|
305
|
+
return req.headers.get('upgrade')?.toLowerCase() === 'websocket'
|
|
306
|
+
}
|
|
307
|
+
|
|
292
308
|
function forwardSessionEvents(ws: Ws, session: AgentSession): void {
|
|
293
309
|
const toolStartedAt = new Map<string, number>()
|
|
294
310
|
|