typeclaw 0.1.4 → 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 +115 -16
- package/src/config/index.ts +8 -1
- package/src/container/index.ts +1 -1
- package/src/container/port.ts +10 -0
- package/src/container/start.ts +46 -9
- package/src/doctor/checks.ts +1 -1
- package/src/doctor/plugin-bridge.ts +28 -4
- package/src/init/dockerfile.ts +4 -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 +77 -53
package/src/container/start.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs'
|
|
|
3
3
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { isAbsolute, join, resolve } from 'node:path'
|
|
5
5
|
|
|
6
|
-
import { configSchema, expandMountPath, type Config } from '@/config
|
|
6
|
+
import { configSchema, expandMountPath, migrateLegacyConfigShape, type Config } from '@/config'
|
|
7
7
|
import { send as sendToDaemon } from '@/hostd/client'
|
|
8
8
|
import type { HttpInfoResult } from '@/hostd/protocol'
|
|
9
9
|
import { ensureDaemon } from '@/hostd/spawn'
|
|
@@ -22,7 +22,7 @@ import { refreshPackageJson } from '@/init/packagejson'
|
|
|
22
22
|
import { runBunUpdate, type UpdateRunner } from '@/init/run-bun-install'
|
|
23
23
|
import { migrateKakaotalkCredentials } from '@/secrets'
|
|
24
24
|
|
|
25
|
-
import { CONTAINER_PORT, findFreePort, isPortAllocatedError } from './port'
|
|
25
|
+
import { CONTAINER_PORT, TUI_TOKEN_LABEL, findFreePort, isPortAllocatedError, resolveTuiToken } from './port'
|
|
26
26
|
import {
|
|
27
27
|
classifyRmStderr,
|
|
28
28
|
cleanupRunCorpse,
|
|
@@ -57,6 +57,7 @@ export type StartPlan = {
|
|
|
57
57
|
runArgs: string[]
|
|
58
58
|
needsBuild: boolean
|
|
59
59
|
hostPort: number
|
|
60
|
+
tuiToken: string | null
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
export type PlanStartOptions = {
|
|
@@ -65,6 +66,8 @@ export type PlanStartOptions = {
|
|
|
65
66
|
imageExists: boolean
|
|
66
67
|
forceBuild?: boolean
|
|
67
68
|
hostdControl?: HostDaemonControl
|
|
69
|
+
publishHost?: string
|
|
70
|
+
tuiToken?: string | null
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
export type HostDaemonControl = {
|
|
@@ -127,6 +130,7 @@ export type StartResult =
|
|
|
127
130
|
containerId: string
|
|
128
131
|
built: boolean
|
|
129
132
|
hostPort: number
|
|
133
|
+
tuiToken: string | null
|
|
130
134
|
hostd: HostDaemonStatus
|
|
131
135
|
// True when the container was already running and start() became a no-op.
|
|
132
136
|
// Callers that want to distinguish "I just launched it" from "it was up
|
|
@@ -298,7 +302,17 @@ export async function start({
|
|
|
298
302
|
: { state: 'disabled' as const }
|
|
299
303
|
let hostdControl = hostd.state === 'registered' ? hostd.control : undefined
|
|
300
304
|
|
|
301
|
-
|
|
305
|
+
const publishHost = await resolvePublishHost(exec)
|
|
306
|
+
const tuiToken = randomBytes(32).toString('base64url')
|
|
307
|
+
let plan = await planStart({
|
|
308
|
+
cwd,
|
|
309
|
+
hostPort,
|
|
310
|
+
imageExists: imageExisted,
|
|
311
|
+
forceBuild,
|
|
312
|
+
hostdControl,
|
|
313
|
+
publishHost,
|
|
314
|
+
tuiToken,
|
|
315
|
+
})
|
|
302
316
|
|
|
303
317
|
let built = false
|
|
304
318
|
if (plan.needsBuild) {
|
|
@@ -347,7 +361,15 @@ export async function start({
|
|
|
347
361
|
hostd = await registerWithDaemon({ cwd, containerName, cliEntry, hostPort, reuseCurrentHostDaemon })
|
|
348
362
|
hostdControl = hostd.state === 'registered' ? hostd.control : undefined
|
|
349
363
|
}
|
|
350
|
-
plan = await planStart({
|
|
364
|
+
plan = await planStart({
|
|
365
|
+
cwd,
|
|
366
|
+
hostPort,
|
|
367
|
+
imageExists: true,
|
|
368
|
+
forceBuild: false,
|
|
369
|
+
hostdControl,
|
|
370
|
+
publishHost,
|
|
371
|
+
tuiToken,
|
|
372
|
+
})
|
|
351
373
|
run = await execRunWithConflictRetry(exec, plan.runArgs, cwd, containerName)
|
|
352
374
|
}
|
|
353
375
|
|
|
@@ -370,6 +392,7 @@ export async function start({
|
|
|
370
392
|
containerId,
|
|
371
393
|
built,
|
|
372
394
|
hostPort,
|
|
395
|
+
tuiToken,
|
|
373
396
|
hostd: stripHostDaemonControl(hostd),
|
|
374
397
|
alreadyRunning: false,
|
|
375
398
|
autoUpgrade: upgrade,
|
|
@@ -403,6 +426,8 @@ export async function planStart({
|
|
|
403
426
|
imageExists,
|
|
404
427
|
forceBuild = false,
|
|
405
428
|
hostdControl,
|
|
429
|
+
publishHost = '127.0.0.1',
|
|
430
|
+
tuiToken = null,
|
|
406
431
|
}: PlanStartOptions): Promise<StartPlan> {
|
|
407
432
|
const containerName = containerNameFromCwd(cwd)
|
|
408
433
|
const imageTag = imageTagFromCwd(cwd)
|
|
@@ -416,7 +441,7 @@ export async function planStart({
|
|
|
416
441
|
// the start() preflight force-removes any lingering corpse before the next
|
|
417
442
|
// launch — so the only state Docker ever sees in `docker ps -a` is either
|
|
418
443
|
// a running container or one the user has not started again yet.
|
|
419
|
-
const runArgs = ['run', '-d', '--name', containerName, '-p',
|
|
444
|
+
const runArgs = ['run', '-d', '--name', containerName, '-p', `${publishHost}:${hostPort}:${CONTAINER_PORT}`]
|
|
420
445
|
|
|
421
446
|
// Network egress filter: when `typeclaw.json#network.blockInternal` is true,
|
|
422
447
|
// grant the container CAP_NET_ADMIN at boot so the entrypoint shim can
|
|
@@ -443,6 +468,9 @@ export async function planStart({
|
|
|
443
468
|
for (const [key, value] of Object.entries(composeLabels(cwd, containerName))) {
|
|
444
469
|
runArgs.push('--label', `${key}=${value}`)
|
|
445
470
|
}
|
|
471
|
+
if (tuiToken !== null) {
|
|
472
|
+
runArgs.push('--label', `${TUI_TOKEN_LABEL}=${tuiToken}`, '-e', `TYPECLAW_TUI_TOKEN=${tuiToken}`)
|
|
473
|
+
}
|
|
446
474
|
|
|
447
475
|
if (existsSync(join(cwd, ENV_FILE))) {
|
|
448
476
|
runArgs.push('--env-file', join(cwd, ENV_FILE))
|
|
@@ -493,20 +521,27 @@ export async function planStart({
|
|
|
493
521
|
runArgs,
|
|
494
522
|
needsBuild: forceBuild || !imageExists,
|
|
495
523
|
hostPort,
|
|
524
|
+
tuiToken,
|
|
496
525
|
}
|
|
497
526
|
}
|
|
498
527
|
|
|
528
|
+
async function resolvePublishHost(exec: DockerExec): Promise<string> {
|
|
529
|
+
const result = await exec(['version', '--format', '{{.Server.Platform.Name}}'])
|
|
530
|
+
if (result.exitCode === 0 && result.stdout.toLowerCase().includes('docker desktop')) return '0.0.0.0'
|
|
531
|
+
return '127.0.0.1'
|
|
532
|
+
}
|
|
533
|
+
|
|
499
534
|
export async function refreshDockerfile(cwd: string): Promise<void> {
|
|
500
535
|
const cfg = await loadTypeclawConfig(cwd)
|
|
501
536
|
await writeFile(
|
|
502
537
|
join(cwd, DOCKERFILE),
|
|
503
|
-
buildDockerfile(cfg.
|
|
538
|
+
buildDockerfile(cfg.docker.file, { baseImageVersion: resolveBaseImageVersion(cwd) }),
|
|
504
539
|
)
|
|
505
540
|
}
|
|
506
541
|
|
|
507
542
|
export async function refreshGitignore(cwd: string): Promise<void> {
|
|
508
543
|
const cfg = await loadTypeclawConfig(cwd)
|
|
509
|
-
await writeFile(join(cwd, GITIGNORE_FILE), buildGitignore(cfg.
|
|
544
|
+
await writeFile(join(cwd, GITIGNORE_FILE), buildGitignore(cfg.git.ignore))
|
|
510
545
|
}
|
|
511
546
|
|
|
512
547
|
// Commits TypeClaw-owned system file(s) if any are dirty in git. Skips silently
|
|
@@ -623,13 +658,15 @@ async function reportAlreadyRunning(exec: DockerExec, cwd: string, containerName
|
|
|
623
658
|
reason: `Container ${containerName} is running but its published host port could not be resolved.`,
|
|
624
659
|
}
|
|
625
660
|
}
|
|
626
|
-
const
|
|
661
|
+
const tuiToken = await resolveTuiToken({ cwd, exec })
|
|
662
|
+
const plan = await planStart({ cwd, hostPort, imageExists: true, forceBuild: false, tuiToken })
|
|
627
663
|
return {
|
|
628
664
|
ok: true,
|
|
629
665
|
plan,
|
|
630
666
|
containerId,
|
|
631
667
|
built: false,
|
|
632
668
|
hostPort,
|
|
669
|
+
tuiToken,
|
|
633
670
|
hostd: { state: 'disabled' },
|
|
634
671
|
alreadyRunning: true,
|
|
635
672
|
autoUpgrade: { kind: 'skipped-already-running' },
|
|
@@ -747,7 +784,7 @@ async function loadConfigJson(cwd: string): Promise<unknown> {
|
|
|
747
784
|
} catch {
|
|
748
785
|
return {}
|
|
749
786
|
}
|
|
750
|
-
return JSON.parse(raw)
|
|
787
|
+
return migrateLegacyConfigShape(JSON.parse(raw)).json
|
|
751
788
|
}
|
|
752
789
|
|
|
753
790
|
type PreparedHostDaemonStatus =
|
package/src/doctor/checks.ts
CHANGED
|
@@ -390,7 +390,7 @@ function loadConfigStrictForTemplate(
|
|
|
390
390
|
const result = validateConfig(cwd)
|
|
391
391
|
if (!result.ok) return null
|
|
392
392
|
const cfg = loadConfigSync(cwd)
|
|
393
|
-
return { dockerfile: cfg.
|
|
393
|
+
return { dockerfile: cfg.docker.file, gitignore: cfg.git.ignore }
|
|
394
394
|
}
|
|
395
395
|
|
|
396
396
|
async function safeRead(path: string): Promise<string | null> {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolveHostPort } from '@/container'
|
|
1
|
+
import { resolveHostPort, resolveTuiToken } from '@/container'
|
|
2
2
|
import type { ClientMessage, DoctorCheckPayload, DoctorFixPayload, ServerMessage } from '@/shared'
|
|
3
3
|
|
|
4
4
|
export type PluginBridgeOptions = {
|
|
@@ -80,12 +80,14 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
|
|
|
80
80
|
if (url === undefined) {
|
|
81
81
|
try {
|
|
82
82
|
const port = await resolveHostPort({ cwd: opts.cwd })
|
|
83
|
-
|
|
83
|
+
const token = await resolveTuiToken({ cwd: opts.cwd })
|
|
84
|
+
url = buildBridgeUrl(port, token)
|
|
84
85
|
} catch (err) {
|
|
85
86
|
return { kind: 'unreachable', reason: err instanceof Error ? err.message : String(err) }
|
|
86
87
|
}
|
|
87
88
|
}
|
|
88
89
|
const ws = new WebSocket(url)
|
|
90
|
+
const displayUrl = redactUrl(url)
|
|
89
91
|
try {
|
|
90
92
|
await new Promise<void>((resolve, reject) => {
|
|
91
93
|
// `timer` is declared up front so `cleanup` can reference it without
|
|
@@ -97,6 +99,7 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
|
|
|
97
99
|
if (timer !== undefined) clearTimeout(timer)
|
|
98
100
|
ws.removeEventListener('open', onOpen)
|
|
99
101
|
ws.removeEventListener('error', onError)
|
|
102
|
+
ws.removeEventListener('close', onClose)
|
|
100
103
|
}
|
|
101
104
|
const onOpen = () => {
|
|
102
105
|
cleanup()
|
|
@@ -104,7 +107,11 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
|
|
|
104
107
|
}
|
|
105
108
|
const onError = (err: unknown) => {
|
|
106
109
|
cleanup()
|
|
107
|
-
reject(
|
|
110
|
+
reject(new Error(`failed to connect to ${displayUrl}: ${err instanceof Error ? err.message : String(err)}`))
|
|
111
|
+
}
|
|
112
|
+
const onClose = () => {
|
|
113
|
+
cleanup()
|
|
114
|
+
reject(new Error(`connection to ${displayUrl} closed before opening`))
|
|
108
115
|
}
|
|
109
116
|
// Bun's WebSocket has no built-in connect timeout. Without this, a TCP
|
|
110
117
|
// handshake that completes but never produces an Upgrade response
|
|
@@ -117,10 +124,11 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
|
|
|
117
124
|
try {
|
|
118
125
|
ws.close()
|
|
119
126
|
} catch {}
|
|
120
|
-
reject(new Error(`
|
|
127
|
+
reject(new Error(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
|
|
121
128
|
}, timeoutMs)
|
|
122
129
|
ws.addEventListener('open', onOpen, { once: true })
|
|
123
130
|
ws.addEventListener('error', onError, { once: true })
|
|
131
|
+
ws.addEventListener('close', onClose, { once: true })
|
|
124
132
|
})
|
|
125
133
|
} catch (err) {
|
|
126
134
|
return { kind: 'unreachable', reason: err instanceof Error ? err.message : String(err) }
|
|
@@ -128,6 +136,22 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
|
|
|
128
136
|
return { kind: 'ok', ws, timeoutMs }
|
|
129
137
|
}
|
|
130
138
|
|
|
139
|
+
function buildBridgeUrl(port: number, token: string | null): string {
|
|
140
|
+
const url = new URL(`ws://127.0.0.1:${port}`)
|
|
141
|
+
if (token !== null) url.searchParams.set('token', token)
|
|
142
|
+
return url.toString()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function redactUrl(url: string): string {
|
|
146
|
+
try {
|
|
147
|
+
const parsed = new URL(url)
|
|
148
|
+
if (parsed.searchParams.has('token')) parsed.searchParams.set('token', '<redacted>')
|
|
149
|
+
return parsed.toString()
|
|
150
|
+
} catch {
|
|
151
|
+
return url
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
131
155
|
async function withRequest<R extends { kind: string }>(
|
|
132
156
|
ws: WebSocket,
|
|
133
157
|
timeoutMs: number,
|
package/src/init/dockerfile.ts
CHANGED
|
@@ -384,7 +384,7 @@ ${ghKeyringLayer}# Layer 2 (changes when the package list changes): the actual a
|
|
|
384
384
|
# Cache mounts make a re-install nearly free when this layer is invalidated:
|
|
385
385
|
# .deb files come straight from the host's BuildKit cache instead of being
|
|
386
386
|
# refetched from Debian/GitHub mirrors. Package set is composed from the
|
|
387
|
-
# \`
|
|
387
|
+
# \`docker.file\` config block in typeclaw.json — toggles for tmux/python/gh/
|
|
388
388
|
# ffmpeg fan out into the args below. Baseline (git/ca-certificates/curl/
|
|
389
389
|
# gnupg) is always installed because downstream layers depend on it.
|
|
390
390
|
#
|
|
@@ -417,7 +417,7 @@ ${renderEntrypointShimLayer()}
|
|
|
417
417
|
|
|
418
418
|
function renderToggleAptInstallLayer(toggleAptArgs: string[]): string {
|
|
419
419
|
return `# Layer 1 (toggle apt install): packages requested via typeclaw.json
|
|
420
|
-
# #
|
|
420
|
+
# #docker.file toggles. Baseline + Chrome runtime libs are already in the
|
|
421
421
|
# base image; this layer only adds gh/tmux/python/ffmpeg if enabled.
|
|
422
422
|
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
|
|
423
423
|
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\
|
|
@@ -432,7 +432,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
|
|
|
432
432
|
// two cannot drift — the published image is a function of this source, not
|
|
433
433
|
// a checked-in Dockerfile that needs hand-syncing. The base intentionally
|
|
434
434
|
// stops before the per-agent layers (gh keyring, apt feature toggles,
|
|
435
|
-
//
|
|
435
|
+
// docker.file.append, ENV, ENTRYPOINT) so users can still toggle them via
|
|
436
436
|
// typeclaw.json without forcing a base-image rebuild.
|
|
437
437
|
//
|
|
438
438
|
// Layer 2's apt-get install line installs only the baseline packages, NOT
|
|
@@ -587,7 +587,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
|
|
|
587
587
|
|
|
588
588
|
function renderCustomDockerfileLines(lines: string[]): string {
|
|
589
589
|
if (lines.length === 0) return ''
|
|
590
|
-
return `# Custom lines from typeclaw.json#
|
|
590
|
+
return `# Custom lines from typeclaw.json#docker.file.append.
|
|
591
591
|
${lines.join('\n')}
|
|
592
592
|
|
|
593
593
|
`
|
package/src/init/gitignore.ts
CHANGED
|
@@ -39,7 +39,7 @@ channels/
|
|
|
39
39
|
|
|
40
40
|
function renderCustomGitignoreEntries(entries: string[]): string {
|
|
41
41
|
if (entries.length === 0) return ''
|
|
42
|
-
return `# Custom entries from typeclaw.json#
|
|
42
|
+
return `# Custom entries from typeclaw.json#git.ignore.append.
|
|
43
43
|
${entries.join('\n')}
|
|
44
44
|
|
|
45
45
|
`
|
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()
|