typeclaw 0.1.1 → 0.1.3
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 +16 -12
- package/auth.schema.json +238 -7
- package/package.json +1 -1
- package/secrets.schema.json +238 -7
- package/src/agent/auth.ts +19 -38
- package/src/agent/doctor.ts +173 -0
- package/src/agent/subagents.ts +24 -2
- package/src/agent/tools/channel-fetch-attachment.ts +6 -0
- package/src/agent/tools/channel-history.ts +10 -1
- package/src/agent/tools/channel-log.ts +32 -0
- package/src/agent/tools/channel-reply.ts +18 -1
- package/src/agent/tools/channel-send.ts +13 -1
- package/src/bundled-plugins/backup/README.md +81 -0
- package/src/bundled-plugins/backup/index.ts +209 -0
- package/src/bundled-plugins/backup/runner.ts +231 -0
- package/src/bundled-plugins/backup/subagents.ts +200 -0
- package/src/bundled-plugins/memory/index.ts +42 -1
- package/src/bundled-plugins/tool-result-cap/README.md +67 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
- package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
- package/src/channels/adapters/kakaotalk.ts +25 -16
- package/src/channels/manager.ts +47 -38
- package/src/channels/router.ts +29 -0
- package/src/cli/channel.ts +3 -3
- package/src/cli/compose.ts +92 -1
- package/src/cli/doctor.ts +100 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +2 -1
- package/src/cli/ui.ts +11 -0
- package/src/compose/doctor.ts +141 -0
- package/src/compose/index.ts +8 -0
- package/src/compose/logs.ts +32 -19
- package/src/config/config.ts +31 -0
- package/src/container/log-colors.ts +75 -0
- package/src/container/log-timestamps.ts +84 -0
- package/src/container/logs.ts +71 -5
- package/src/container/start.ts +113 -9
- package/src/cron/consumer.ts +29 -7
- package/src/doctor/checks.ts +426 -0
- package/src/doctor/commit.ts +71 -0
- package/src/doctor/index.ts +287 -0
- package/src/doctor/plugin-bridge.ts +147 -0
- package/src/doctor/report.ts +142 -0
- package/src/doctor/types.ts +87 -0
- package/src/hostd/daemon.ts +28 -3
- package/src/hostd/protocol.ts +7 -0
- package/src/init/auto-upgrade.ts +368 -0
- package/src/init/cli-version.ts +81 -0
- package/src/init/dockerfile.ts +234 -25
- package/src/init/index.ts +141 -87
- package/src/init/kakaotalk-auth.ts +9 -3
- package/src/init/run-bun-install.ts +34 -0
- package/src/plugin/hooks.ts +32 -0
- package/src/plugin/index.ts +7 -0
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/registry.ts +32 -3
- package/src/plugin/types.ts +65 -0
- package/src/run/bundled-plugins.ts +15 -0
- package/src/run/index.ts +19 -5
- package/src/secrets/defaults.ts +67 -0
- package/src/secrets/hydrate.ts +99 -0
- package/src/secrets/index.ts +6 -12
- package/src/secrets/kakao-store.ts +129 -0
- package/src/secrets/migrate-kakaotalk.ts +82 -0
- package/src/secrets/migrate.ts +5 -4
- package/src/secrets/resolve.ts +57 -0
- package/src/secrets/schema.ts +162 -42
- package/src/secrets/storage.ts +253 -47
- package/src/server/index.ts +103 -5
- package/src/shared/index.ts +3 -0
- package/src/shared/protocol.ts +22 -0
- package/src/skills/typeclaw-config/SKILL.md +48 -9
- package/typeclaw.schema.json +84 -0
- package/src/secrets/env.ts +0 -43
package/src/init/index.ts
CHANGED
|
@@ -6,8 +6,10 @@ import { fileURLToPath } from 'node:url'
|
|
|
6
6
|
import { config, configSchema, type Config } from '@/config'
|
|
7
7
|
import { DEFAULT_MODEL_REF, KNOWN_PROVIDERS, providerForModelRef, type KnownModelRef } from '@/config/providers'
|
|
8
8
|
import { checkDockerAvailable, type DockerAvailability, type DockerExec, start } from '@/container'
|
|
9
|
+
import { type Channels, type Secret, SecretsBackend } from '@/secrets'
|
|
9
10
|
import { createTui } from '@/tui'
|
|
10
11
|
|
|
12
|
+
import { resolveBaseImageVersion, resolveScaffoldVersion } from './cli-version'
|
|
11
13
|
import { buildDockerfile, DOCKERFILE } from './dockerfile'
|
|
12
14
|
import { buildGitignore, GITIGNORE_FILE } from './gitignore'
|
|
13
15
|
import { HATCHING_PROMPT } from './hatching'
|
|
@@ -68,7 +70,13 @@ export type InitStepEvent =
|
|
|
68
70
|
| { step: 'hatching'; phase: 'start' }
|
|
69
71
|
| { step: 'hatching'; phase: 'done'; result: HatchingResult }
|
|
70
72
|
|
|
71
|
-
|
|
73
|
+
// `cliEntry` is the path to the running CLI module (typically `process.argv[1]`).
|
|
74
|
+
// When provided, the hatching step threads it into `start()`, which spawns the
|
|
75
|
+
// host daemon and registers the freshly-hatched container with the supervisor +
|
|
76
|
+
// portbroker — same path `typeclaw start` takes. When omitted (test fixtures,
|
|
77
|
+
// programmatic callers that never want a daemon), `start()` skips the daemon
|
|
78
|
+
// path entirely and the container runs unmanaged.
|
|
79
|
+
export type HatchRunner = (options: { cwd: string; port: number; cliEntry?: string }) => Promise<HatchingResult>
|
|
72
80
|
|
|
73
81
|
export type KakaotalkAuthRunner = (options: { cwd: string }) => Promise<KakaotalkAuthResult>
|
|
74
82
|
|
|
@@ -103,6 +111,12 @@ export type InitOptions = {
|
|
|
103
111
|
runHatching?: HatchRunner
|
|
104
112
|
runBunInstall?: InstallRunner
|
|
105
113
|
dockerExec?: DockerExec
|
|
114
|
+
// Production CLI callers (src/cli/init.ts) pass `process.argv[1]` so the
|
|
115
|
+
// hatching step's `start()` call spawns the host daemon and registers the
|
|
116
|
+
// freshly-hatched container — same path `typeclaw start` takes. Tests omit
|
|
117
|
+
// this to skip the daemon entirely (matching the existing seam in
|
|
118
|
+
// src/container/start.ts).
|
|
119
|
+
cliEntry?: string
|
|
106
120
|
}
|
|
107
121
|
|
|
108
122
|
export async function runInit({
|
|
@@ -124,6 +138,7 @@ export async function runInit({
|
|
|
124
138
|
runHatching = defaultRunHatching,
|
|
125
139
|
runBunInstall: installRunner = runBunInstall,
|
|
126
140
|
dockerExec,
|
|
141
|
+
cliEntry,
|
|
127
142
|
}: InitOptions): Promise<void> {
|
|
128
143
|
const emit = onProgress ?? (() => {})
|
|
129
144
|
|
|
@@ -216,13 +231,37 @@ export async function runInit({
|
|
|
216
231
|
emit({ step: 'git', phase: 'done', result: git })
|
|
217
232
|
|
|
218
233
|
emit({ step: 'hatching', phase: 'start' })
|
|
219
|
-
const hatching = await runHatching({ cwd, port: config.port })
|
|
234
|
+
const hatching = await runHatching({ cwd, port: config.port, ...(cliEntry !== undefined ? { cliEntry } : {}) })
|
|
220
235
|
emit({ step: 'hatching', phase: 'done', result: hatching })
|
|
221
236
|
}
|
|
222
237
|
|
|
223
|
-
|
|
238
|
+
// Exported for the composition test in index.test.ts: the seam that the
|
|
239
|
+
// hatching-hostd fix turns on (passing `cliEntry` into `start()`) is the bug
|
|
240
|
+
// site itself, so a guard test that proves `defaultRunHatching` forwards
|
|
241
|
+
// `cliEntry` to `start()` is what blocks the regression from coming back.
|
|
242
|
+
// Tests inject `startContainer` and `tui` to avoid Docker / TUI side effects;
|
|
243
|
+
// production callers omit both and get the real `start` + `createTui`.
|
|
244
|
+
export async function defaultRunHatching({
|
|
245
|
+
cwd,
|
|
246
|
+
port,
|
|
247
|
+
cliEntry,
|
|
248
|
+
startContainer = start,
|
|
249
|
+
tui: tuiFactory = createTui,
|
|
250
|
+
waitForAgent: waitForAgentFn = waitForAgent,
|
|
251
|
+
}: {
|
|
252
|
+
cwd: string
|
|
253
|
+
port: number
|
|
254
|
+
cliEntry?: string
|
|
255
|
+
startContainer?: typeof start
|
|
256
|
+
tui?: typeof createTui
|
|
257
|
+
waitForAgent?: typeof waitForAgent
|
|
258
|
+
}): Promise<HatchingResult> {
|
|
224
259
|
try {
|
|
225
|
-
const launch = await
|
|
260
|
+
const launch = await startContainer({
|
|
261
|
+
cwd,
|
|
262
|
+
preferredHostPort: port,
|
|
263
|
+
...(cliEntry !== undefined ? { cliEntry } : {}),
|
|
264
|
+
})
|
|
226
265
|
if (!launch.ok) return { ok: false, reason: launch.reason }
|
|
227
266
|
|
|
228
267
|
// start() may have allocated a different host port (the preferred one was
|
|
@@ -230,9 +269,9 @@ async function defaultRunHatching({ cwd, port }: { cwd: string; port: number }):
|
|
|
230
269
|
// the preferred port, otherwise we'd connect to the wrong service.
|
|
231
270
|
const hostPort = launch.hostPort
|
|
232
271
|
|
|
233
|
-
await
|
|
272
|
+
await waitForAgentFn(`http://localhost:${hostPort}`, { timeoutMs: 30_000 })
|
|
234
273
|
|
|
235
|
-
const tui =
|
|
274
|
+
const tui = tuiFactory({
|
|
236
275
|
url: `ws://localhost:${hostPort}`,
|
|
237
276
|
initialPrompt: HATCHING_PROMPT,
|
|
238
277
|
})
|
|
@@ -342,14 +381,18 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
|
|
|
342
381
|
// immediately populated, so packages/ is the only one that needs this.
|
|
343
382
|
await writeFile(join(root, PACKAGES_DIR, GITKEEP_FILE), '', { flag: 'wx' }).catch(ignoreExists)
|
|
344
383
|
|
|
345
|
-
// Only fields without sensible defaults elsewhere are emitted
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
384
|
+
// Only fields without sensible defaults elsewhere are emitted, with one
|
|
385
|
+
// exception: `network.blockInternal` is re-emitted at its default value
|
|
386
|
+
// (`true`) because the field is security-relevant and users need to
|
|
387
|
+
// discover it in their `typeclaw.json` to know they can opt out for LAN
|
|
388
|
+
// access. `mounts` defaults to `[]` in configSchema, and the bundled
|
|
389
|
+
// memory plugin owns its own defaults in src/bundled-plugins/memory/
|
|
390
|
+
// index.ts — re-emitting either here would be duplicate noise the user
|
|
391
|
+
// has to maintain in sync with the source of truth.
|
|
350
392
|
const config: Record<string, unknown> = {
|
|
351
393
|
$schema: './node_modules/typeclaw/typeclaw.schema.json',
|
|
352
394
|
model: options.model ?? DEFAULT_MODEL_REF,
|
|
395
|
+
network: { blockInternal: true },
|
|
353
396
|
}
|
|
354
397
|
const channels: Record<string, { allow: string[] }> = {}
|
|
355
398
|
if (options.withDiscord) channels['discord-bot'] = { allow: options.discordAllowAll === false ? [] : ['*'] }
|
|
@@ -387,24 +430,29 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
|
|
|
387
430
|
const AGENT_BROWSER_VERSION = '^0.26.0'
|
|
388
431
|
|
|
389
432
|
function buildPackageJson(root: string, name: string): Record<string, unknown> {
|
|
390
|
-
const typeclawRoot = findTypeclawRoot()
|
|
391
|
-
// FIXME: temporary dev-stage wiring. Switch to a published version range
|
|
392
|
-
// (e.g. "typeclaw": "^x.y.z") once typeclaw is released. The `file:` spec is
|
|
393
|
-
// computed relative to the agent root because `file:` resolves relative to
|
|
394
|
-
// the consuming package.
|
|
395
|
-
const fileSpec = typeclawRoot ? `file:${toFileSpec(relative(root, typeclawRoot))}` : 'file:../typeclaw'
|
|
396
433
|
return {
|
|
397
434
|
name,
|
|
398
435
|
private: true,
|
|
399
436
|
type: 'module',
|
|
400
437
|
workspaces: [`${PACKAGES_DIR}/*`],
|
|
401
438
|
dependencies: {
|
|
402
|
-
typeclaw:
|
|
439
|
+
typeclaw: resolveTypeclawSpec(root),
|
|
403
440
|
'agent-browser': AGENT_BROWSER_VERSION,
|
|
404
441
|
},
|
|
405
442
|
}
|
|
406
443
|
}
|
|
407
444
|
|
|
445
|
+
// Prefer the registry-style range (`^X.Y.Z`) when typeclaw is itself an
|
|
446
|
+
// installed package — that's what lets `bun install` in the agent resolve
|
|
447
|
+
// typeclaw from npm. Fall back to `file:` against the local checkout for
|
|
448
|
+
// dev contributors running `bun run src/cli/index.ts init` from the repo.
|
|
449
|
+
function resolveTypeclawSpec(agentRoot: string): string {
|
|
450
|
+
const scaffoldVersion = resolveScaffoldVersion()
|
|
451
|
+
if (scaffoldVersion !== null) return scaffoldVersion
|
|
452
|
+
const typeclawRoot = findTypeclawRoot()
|
|
453
|
+
return typeclawRoot ? `file:${toFileSpec(relative(agentRoot, typeclawRoot))}` : 'file:../typeclaw'
|
|
454
|
+
}
|
|
455
|
+
|
|
408
456
|
function toFileSpec(rel: string): string {
|
|
409
457
|
if (rel === '') return '.'
|
|
410
458
|
// bun/npm accept POSIX-style paths in file: specifiers; normalize separators.
|
|
@@ -434,9 +482,11 @@ export async function writeDockerAssets(root: string): Promise<DockerAssetsResul
|
|
|
434
482
|
const devMode = typeclawSpec.startsWith('file:')
|
|
435
483
|
|
|
436
484
|
const typeclawConfig = await readTypeclawConfig(root)
|
|
437
|
-
await writeFile(
|
|
438
|
-
|
|
439
|
-
|
|
485
|
+
await writeFile(
|
|
486
|
+
join(root, DOCKERFILE),
|
|
487
|
+
buildDockerfile(typeclawConfig.dockerfile, { baseImageVersion: resolveBaseImageVersion(root) }),
|
|
488
|
+
{ flag: 'wx' },
|
|
489
|
+
).catch(ignoreExists)
|
|
440
490
|
|
|
441
491
|
return { ok: true, devMode }
|
|
442
492
|
} catch (error) {
|
|
@@ -508,10 +558,15 @@ export async function initGitRepo(cwd: string): Promise<GitInitResult> {
|
|
|
508
558
|
}
|
|
509
559
|
}
|
|
510
560
|
|
|
511
|
-
// Writes the LLM provider's API key (under its provider-specific
|
|
512
|
-
// e.g. OPENAI_API_KEY or FIREWORKS_API_KEY)
|
|
513
|
-
//
|
|
514
|
-
//
|
|
561
|
+
// Writes the LLM provider's API key to `.env` (under its provider-specific
|
|
562
|
+
// env var, e.g. OPENAI_API_KEY or FIREWORKS_API_KEY) and the channel adapter
|
|
563
|
+
// tokens to `secrets.json#channels`. Two stores on purpose: api-keys land in
|
|
564
|
+
// `.env` to match the `--env-file .env` boot contract (env-wins: `auth.ts`
|
|
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`.
|
|
515
570
|
export async function writeSecrets(
|
|
516
571
|
root: string,
|
|
517
572
|
{
|
|
@@ -523,8 +578,9 @@ export async function writeSecrets(
|
|
|
523
578
|
telegramBotToken,
|
|
524
579
|
}: {
|
|
525
580
|
model?: KnownModelRef
|
|
526
|
-
// Omitted on the OAuth path — credentials live in secrets.json instead.
|
|
527
|
-
// .env file still gets written
|
|
581
|
+
// Omitted on the OAuth path — credentials live in secrets.json instead.
|
|
582
|
+
// The .env file still gets written (empty) so post-init callers that
|
|
583
|
+
// read it don't ENOENT-crash.
|
|
528
584
|
apiKey?: string
|
|
529
585
|
discordBotToken?: string
|
|
530
586
|
slackBotToken?: string
|
|
@@ -538,22 +594,37 @@ export async function writeSecrets(
|
|
|
538
594
|
if (apiKey !== undefined && apiKeyEnv !== null) {
|
|
539
595
|
lines.push(`${apiKeyEnv}=${apiKey}`)
|
|
540
596
|
}
|
|
597
|
+
const body = lines.length > 0 ? `${lines.join('\n')}\n` : ''
|
|
598
|
+
await writeFile(join(root, SECRETS_FILE), body)
|
|
599
|
+
|
|
600
|
+
const channelTokens: Record<string, Record<string, Secret>> = {}
|
|
541
601
|
if (discordBotToken !== undefined && discordBotToken !== '') {
|
|
542
|
-
|
|
602
|
+
channelTokens['discord-bot'] = { token: { value: discordBotToken } }
|
|
543
603
|
}
|
|
544
604
|
if (slackBotToken !== undefined && slackBotToken !== '') {
|
|
545
|
-
|
|
605
|
+
channelTokens['slack-bot'] = { ...channelTokens['slack-bot'], botToken: { value: slackBotToken } }
|
|
546
606
|
}
|
|
547
607
|
if (slackAppToken !== undefined && slackAppToken !== '') {
|
|
548
|
-
|
|
608
|
+
channelTokens['slack-bot'] = { ...channelTokens['slack-bot'], appToken: { value: slackAppToken } }
|
|
549
609
|
}
|
|
550
610
|
if (telegramBotToken !== undefined && telegramBotToken !== '') {
|
|
551
|
-
|
|
611
|
+
channelTokens['telegram-bot'] = { token: { value: telegramBotToken } }
|
|
552
612
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
const
|
|
556
|
-
|
|
613
|
+
if (Object.keys(channelTokens).length === 0) return
|
|
614
|
+
|
|
615
|
+
const backend = new SecretsBackend(join(root, 'secrets.json'))
|
|
616
|
+
const existing = backend.readChannelsSync()
|
|
617
|
+
const merged: Channels = { ...existing }
|
|
618
|
+
for (const [adapterId, fields] of Object.entries(channelTokens)) {
|
|
619
|
+
const priorSlot = isObjectRecord(merged[adapterId]) ? { ...(merged[adapterId] as Record<string, unknown>) } : {}
|
|
620
|
+
for (const [k, v] of Object.entries(fields)) priorSlot[k] = v
|
|
621
|
+
merged[adapterId] = priorSlot as Channels[string]
|
|
622
|
+
}
|
|
623
|
+
backend.writeChannelsSync(merged)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
627
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
557
628
|
}
|
|
558
629
|
|
|
559
630
|
function resolveLLMAuth(llmAuth: LLMAuth | undefined, apiKey: string | undefined): LLMAuth {
|
|
@@ -627,7 +698,7 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
|
627
698
|
//
|
|
628
699
|
// We run KakaoTalk auth FIRST so a failed login leaves typeclaw.json and
|
|
629
700
|
// .env untouched. The runtime treats `channels.kakaotalk` without a
|
|
630
|
-
//
|
|
701
|
+
// secrets.json#channels.kakaotalk block as "missing credentials, skip adapter", which silently
|
|
631
702
|
// drops messages — the same trap `runInit` already guards against. Aborting
|
|
632
703
|
// before any file write means the user's next `typeclaw channel add
|
|
633
704
|
// kakaotalk` retry has no half-applied state to clean up.
|
|
@@ -643,7 +714,10 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
|
643
714
|
emit({ step: 'config', phase: 'done' })
|
|
644
715
|
|
|
645
716
|
emit({ step: 'secrets', phase: 'start' })
|
|
646
|
-
|
|
717
|
+
const tokens = channelSecretsFromOptions(options)
|
|
718
|
+
if (Object.keys(tokens).length > 0) {
|
|
719
|
+
await appendChannelSecrets(options.cwd, options.channel, tokens)
|
|
720
|
+
}
|
|
647
721
|
emit({ step: 'secrets', phase: 'done' })
|
|
648
722
|
}
|
|
649
723
|
|
|
@@ -658,13 +732,14 @@ function defaultAllowAll(channel: ChannelKind): boolean {
|
|
|
658
732
|
function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
|
|
659
733
|
switch (options.channel) {
|
|
660
734
|
case 'discord-bot':
|
|
661
|
-
return {
|
|
735
|
+
return { token: options.discordBotToken }
|
|
662
736
|
case 'slack-bot':
|
|
663
|
-
return {
|
|
737
|
+
return { botToken: options.slackBotToken, appToken: options.slackAppToken }
|
|
664
738
|
case 'telegram-bot':
|
|
665
|
-
return {
|
|
739
|
+
return { token: options.telegramBotToken }
|
|
666
740
|
case 'kakaotalk':
|
|
667
|
-
//
|
|
741
|
+
// KakaoTalk auth writes its structured multi-account block directly to
|
|
742
|
+
// secrets.json#channels.kakaotalk before config mutation.
|
|
668
743
|
return {}
|
|
669
744
|
}
|
|
670
745
|
}
|
|
@@ -733,56 +808,35 @@ function buildAllow(channel: ChannelKind, allowAll: boolean): string[] {
|
|
|
733
808
|
return allowAll ? ['*'] : []
|
|
734
809
|
}
|
|
735
810
|
|
|
736
|
-
//
|
|
737
|
-
// existing
|
|
738
|
-
//
|
|
739
|
-
// hand-
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
if (
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
try {
|
|
750
|
-
existing = await readFile(path, 'utf8')
|
|
751
|
-
} catch (error) {
|
|
752
|
-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
753
|
-
throw new Error(
|
|
754
|
-
`${SECRETS_FILE} not found at ${cwd}. Run \`typeclaw init\` before adding channels, or run this command from inside an agent folder.`,
|
|
755
|
-
)
|
|
756
|
-
}
|
|
757
|
-
throw error
|
|
811
|
+
// Writes per-adapter field values into `secrets.json#channels.<adapter>`.
|
|
812
|
+
// Refuses to overwrite existing fields: if the user already has e.g.
|
|
813
|
+
// `botToken` recorded (from a prior `channel add` whose follow-up steps
|
|
814
|
+
// failed, or a hand-edit), we surface that as a hard error rather than
|
|
815
|
+
// silently displace it. Same trap the original .env-append path guarded
|
|
816
|
+
// against, applied to the field-keyed destination.
|
|
817
|
+
async function appendChannelSecrets(cwd: string, channel: ChannelKind, tokens: ChannelSecrets): Promise<void> {
|
|
818
|
+
if (Object.keys(tokens).length === 0) return
|
|
819
|
+
|
|
820
|
+
if (!existsSync(join(cwd, CONFIG_FILE))) {
|
|
821
|
+
throw new Error(
|
|
822
|
+
`${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` before adding channels, or run this command from inside an agent folder.`,
|
|
823
|
+
)
|
|
758
824
|
}
|
|
759
825
|
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
|
|
826
|
+
const backend = new SecretsBackend(join(cwd, 'secrets.json'))
|
|
827
|
+
const channels: Record<string, unknown> = backend.readChannelsSync()
|
|
828
|
+
const slot: Record<string, unknown> = isObjectRecord(channels[channel])
|
|
829
|
+
? { ...(channels[channel] as Record<string, unknown>) }
|
|
830
|
+
: {}
|
|
831
|
+
|
|
832
|
+
for (const field of Object.keys(tokens)) {
|
|
833
|
+
if (slot[field] !== undefined) {
|
|
763
834
|
throw new Error(
|
|
764
|
-
`${
|
|
835
|
+
`${field} is already set in secrets.json under "${channel}". Remove it before re-adding the channel, or edit the value by hand.`,
|
|
765
836
|
)
|
|
766
837
|
}
|
|
767
838
|
}
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
const trailingNewline = existing.endsWith('\n') || existing === '' ? '' : '\n'
|
|
772
|
-
const appended = Object.entries(secrets)
|
|
773
|
-
.map(([k, v]) => `${k}=${v}`)
|
|
774
|
-
.join('\n')
|
|
775
|
-
await writeFile(path, `${existing}${trailingNewline}${appended}\n`)
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
function parseEnvKeys(content: string): Set<string> {
|
|
779
|
-
const keys = new Set<string>()
|
|
780
|
-
for (const rawLine of content.split('\n')) {
|
|
781
|
-
const line = rawLine.trim()
|
|
782
|
-
if (line === '' || line.startsWith('#')) continue
|
|
783
|
-
const eq = line.indexOf('=')
|
|
784
|
-
if (eq <= 0) continue
|
|
785
|
-
keys.add(line.slice(0, eq).trim())
|
|
786
|
-
}
|
|
787
|
-
return keys
|
|
839
|
+
for (const [k, v] of Object.entries(tokens)) slot[k] = { value: v } satisfies Secret
|
|
840
|
+
channels[channel] = slot
|
|
841
|
+
backend.writeChannelsSync(channels as Channels)
|
|
788
842
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire } from 'node:module'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
|
|
5
5
|
|
|
6
6
|
export type KakaotalkBootstrapStatus = { ok: true } | { ok: false; reason: string }
|
|
7
7
|
|
|
@@ -50,11 +50,17 @@ export function kakaotalkConfigDir(agentDir: string): string {
|
|
|
50
50
|
return join(agentDir, 'workspace', '.agent-messenger')
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
export function kakaotalkSecretsPath(agentDir: string): string {
|
|
54
|
+
return join(agentDir, 'secrets.json')
|
|
55
|
+
}
|
|
56
|
+
|
|
53
57
|
export async function runKakaotalkBootstrap(input: KakaotalkLoginInput): Promise<KakaotalkBootstrapStatus> {
|
|
54
|
-
const configDir = kakaotalkConfigDir(input.agentDir)
|
|
55
58
|
try {
|
|
56
59
|
const loginFlow = input.loginFlow ?? (await resolveLoginFlow())
|
|
57
|
-
const credManager = new
|
|
60
|
+
const credManager = new SecretsKakaoCredentialStore({
|
|
61
|
+
mode: 'host',
|
|
62
|
+
secretsPath: kakaotalkSecretsPath(input.agentDir),
|
|
63
|
+
})
|
|
58
64
|
const pending = await credManager.loadPendingLogin()
|
|
59
65
|
const existing = await credManager.getAccount()
|
|
60
66
|
const savedDeviceUuid =
|
|
@@ -34,3 +34,37 @@ export async function runBunInstall(cwd: string): Promise<InstallResult> {
|
|
|
34
34
|
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
|
|
38
|
+
// Signature for the function that re-resolves a SINGLE dep against its
|
|
39
|
+
// current spec. Distinct from InstallRunner because the auto-upgrade path
|
|
40
|
+
// MUST force re-resolution against the registry (lockfile-honoring `bun
|
|
41
|
+
// install` would no-op when the existing lockfile entry already satisfies
|
|
42
|
+
// an in-range spec — which is the exact regression auto-upgrade exists to
|
|
43
|
+
// prevent).
|
|
44
|
+
export type UpdateRunner = (cwd: string, pkg: string) => Promise<InstallResult>
|
|
45
|
+
|
|
46
|
+
export async function runBunUpdate(cwd: string, pkg: string): Promise<InstallResult> {
|
|
47
|
+
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
48
|
+
if (!bun) return { ok: false, reason: 'bun runtime not available' }
|
|
49
|
+
try {
|
|
50
|
+
const proc = bun.spawn({
|
|
51
|
+
// `bun update <pkg> --latest` re-resolves <pkg> against the registry,
|
|
52
|
+
// capped by the spec in package.json. For a caret/tilde range this
|
|
53
|
+
// pulls the highest in-range version (the case `bun install` won't
|
|
54
|
+
// upgrade because the lockfile already satisfies the spec). For an
|
|
55
|
+
// exact pin it's effectively a force re-fetch of that exact version.
|
|
56
|
+
// `--linker=hoisted` for the same Bun 1.3.x deadlock reason as
|
|
57
|
+
// runBunInstall above.
|
|
58
|
+
cmd: ['bun', 'update', pkg, '--latest', '--linker=hoisted'],
|
|
59
|
+
cwd,
|
|
60
|
+
stdout: 'pipe',
|
|
61
|
+
stderr: 'pipe',
|
|
62
|
+
})
|
|
63
|
+
const code = await proc.exited
|
|
64
|
+
if (code === 0) return { ok: true }
|
|
65
|
+
const stderr = await new Response(proc.stderr).text()
|
|
66
|
+
return { ok: false, reason: `bun update ${pkg} exited with code ${code}: ${stderr.trim() || 'no stderr'}` }
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/plugin/hooks.ts
CHANGED
|
@@ -6,6 +6,8 @@ import type {
|
|
|
6
6
|
SessionIdleEvent,
|
|
7
7
|
SessionPromptEvent,
|
|
8
8
|
SessionStartEvent,
|
|
9
|
+
SessionTurnEndEvent,
|
|
10
|
+
SessionTurnStartEvent,
|
|
9
11
|
ToolAfterEvent,
|
|
10
12
|
ToolBeforeEvent,
|
|
11
13
|
ToolBeforeResult,
|
|
@@ -43,6 +45,8 @@ export type HookBus = {
|
|
|
43
45
|
runSessionEnd: (event: SessionEndEvent) => Promise<void>
|
|
44
46
|
runSessionIdle: (event: SessionIdleEvent) => Promise<void>
|
|
45
47
|
runSessionPrompt: (event: SessionPromptEvent) => Promise<void>
|
|
48
|
+
runSessionTurnStart: (event: SessionTurnStartEvent) => Promise<void>
|
|
49
|
+
runSessionTurnEnd: (event: SessionTurnEndEvent) => Promise<void>
|
|
46
50
|
runToolBefore: (event: ToolBeforeEvent) => Promise<{ block: true; reason: string } | undefined>
|
|
47
51
|
runToolAfter: (event: ToolAfterEvent) => Promise<void>
|
|
48
52
|
count: (name: keyof Hooks) => number
|
|
@@ -62,6 +66,8 @@ type Registries = {
|
|
|
62
66
|
'session.end': RegisteredHook<'session.end'>[]
|
|
63
67
|
'session.idle': RegisteredHook<'session.idle'>[]
|
|
64
68
|
'session.prompt': RegisteredHook<'session.prompt'>[]
|
|
69
|
+
'session.turn.start': RegisteredHook<'session.turn.start'>[]
|
|
70
|
+
'session.turn.end': RegisteredHook<'session.turn.end'>[]
|
|
65
71
|
'tool.before': RegisteredHook<'tool.before'>[]
|
|
66
72
|
'tool.after': RegisteredHook<'tool.after'>[]
|
|
67
73
|
}
|
|
@@ -74,6 +80,8 @@ export function createHookBus(options: CreateHookBusOptions = {}): HookBus {
|
|
|
74
80
|
'session.end': [],
|
|
75
81
|
'session.idle': [],
|
|
76
82
|
'session.prompt': [],
|
|
83
|
+
'session.turn.start': [],
|
|
84
|
+
'session.turn.end': [],
|
|
77
85
|
'tool.before': [],
|
|
78
86
|
'tool.after': [],
|
|
79
87
|
}
|
|
@@ -89,6 +97,8 @@ export function createHookBus(options: CreateHookBusOptions = {}): HookBus {
|
|
|
89
97
|
if (hooks['session.end']) r['session.end'].push({ ...base, handler: hooks['session.end'] })
|
|
90
98
|
if (hooks['session.idle']) r['session.idle'].push({ ...base, handler: hooks['session.idle'] })
|
|
91
99
|
if (hooks['session.prompt']) r['session.prompt'].push({ ...base, handler: hooks['session.prompt'] })
|
|
100
|
+
if (hooks['session.turn.start']) r['session.turn.start'].push({ ...base, handler: hooks['session.turn.start'] })
|
|
101
|
+
if (hooks['session.turn.end']) r['session.turn.end'].push({ ...base, handler: hooks['session.turn.end'] })
|
|
92
102
|
if (hooks['tool.before']) r['tool.before'].push({ ...base, handler: hooks['tool.before'] })
|
|
93
103
|
if (hooks['tool.after']) r['tool.after'].push({ ...base, handler: hooks['tool.after'] })
|
|
94
104
|
},
|
|
@@ -98,6 +108,8 @@ export function createHookBus(options: CreateHookBusOptions = {}): HookBus {
|
|
|
98
108
|
r['session.end'] = r['session.end'].filter((h) => h.pluginName !== pluginName)
|
|
99
109
|
r['session.idle'] = r['session.idle'].filter((h) => h.pluginName !== pluginName)
|
|
100
110
|
r['session.prompt'] = r['session.prompt'].filter((h) => h.pluginName !== pluginName)
|
|
111
|
+
r['session.turn.start'] = r['session.turn.start'].filter((h) => h.pluginName !== pluginName)
|
|
112
|
+
r['session.turn.end'] = r['session.turn.end'].filter((h) => h.pluginName !== pluginName)
|
|
101
113
|
r['tool.before'] = r['tool.before'].filter((h) => h.pluginName !== pluginName)
|
|
102
114
|
r['tool.after'] = r['tool.after'].filter((h) => h.pluginName !== pluginName)
|
|
103
115
|
},
|
|
@@ -150,6 +162,26 @@ export function createHookBus(options: CreateHookBusOptions = {}): HookBus {
|
|
|
150
162
|
}
|
|
151
163
|
},
|
|
152
164
|
|
|
165
|
+
async runSessionTurnStart(event) {
|
|
166
|
+
for (const reg of r['session.turn.start']) {
|
|
167
|
+
try {
|
|
168
|
+
await reg.handler(event, ctx(reg))
|
|
169
|
+
} catch (err) {
|
|
170
|
+
reportHookError(reg, 'session.turn.start', err)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
async runSessionTurnEnd(event) {
|
|
176
|
+
for (const reg of r['session.turn.end']) {
|
|
177
|
+
try {
|
|
178
|
+
await reg.handler(event, ctx(reg))
|
|
179
|
+
} catch (err) {
|
|
180
|
+
reportHookError(reg, 'session.turn.end', err)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
|
|
153
185
|
// First plugin to return `{ block: true, reason }` short-circuits. Earlier
|
|
154
186
|
// plugins' arg mutations remain visible to later plugins via the shared
|
|
155
187
|
// event.args object.
|
package/src/plugin/index.ts
CHANGED
|
@@ -18,10 +18,16 @@ export type {
|
|
|
18
18
|
HookContext,
|
|
19
19
|
HookName,
|
|
20
20
|
Hooks,
|
|
21
|
+
PluginCheckResult,
|
|
22
|
+
PluginCheckStatus,
|
|
21
23
|
PluginContext,
|
|
22
24
|
PluginCronJob,
|
|
25
|
+
PluginDoctorCheck,
|
|
26
|
+
PluginDoctorContext,
|
|
23
27
|
PluginExecCronJob,
|
|
24
28
|
PluginExports,
|
|
29
|
+
PluginFixResult,
|
|
30
|
+
PluginFixSuggestion,
|
|
25
31
|
PluginLogger,
|
|
26
32
|
PluginPromptCronJob,
|
|
27
33
|
PluginSkill,
|
|
@@ -55,6 +61,7 @@ export {
|
|
|
55
61
|
buildPluginCronGlobalId,
|
|
56
62
|
type PluginRegistry,
|
|
57
63
|
type RegisteredCronJob,
|
|
64
|
+
type RegisteredDoctorCheck,
|
|
58
65
|
type RegisteredSubagent,
|
|
59
66
|
type RegisteredTool,
|
|
60
67
|
type RegisteredSkillEntry,
|
package/src/plugin/manager.ts
CHANGED
|
@@ -93,6 +93,7 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
|
|
|
93
93
|
registry,
|
|
94
94
|
hooks,
|
|
95
95
|
agentDir: opts.agentDir,
|
|
96
|
+
pluginConfig: validatedConfig,
|
|
96
97
|
})
|
|
97
98
|
} catch (err) {
|
|
98
99
|
discardRegistrationsBy(resolved.name, registry, hooks)
|
|
@@ -123,6 +124,7 @@ export function summarizeLoaded(loaded: LoadPluginsResult['loadedPlugins'], regi
|
|
|
123
124
|
`${registry.cronJobs.length} cron job(s)`,
|
|
124
125
|
`${registry.skills.length} skill(s)`,
|
|
125
126
|
`${registry.skillsDirs.length} skills dir(s)`,
|
|
127
|
+
`${registry.doctorChecks.length} doctor check(s)`,
|
|
126
128
|
].join(', ')
|
|
127
129
|
return `${loaded.length} plugin(s): ${head} [${counts}]`
|
|
128
130
|
}
|
package/src/plugin/registry.ts
CHANGED
|
@@ -3,13 +3,28 @@ import { existsSync } from 'node:fs'
|
|
|
3
3
|
import type { CronJob, PromptJob } from '@/cron'
|
|
4
4
|
|
|
5
5
|
import type { HookBus } from './hooks'
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
PluginCronJob,
|
|
8
|
+
PluginDoctorCheck,
|
|
9
|
+
PluginExports,
|
|
10
|
+
PluginLogger,
|
|
11
|
+
PluginSkill,
|
|
12
|
+
Subagent,
|
|
13
|
+
Tool,
|
|
14
|
+
} from './types'
|
|
7
15
|
|
|
8
16
|
export type RegisteredTool = { pluginName: string; toolName: string; tool: Tool<any>; logger: PluginLogger }
|
|
9
17
|
export type RegisteredSubagent = { pluginName: string; subagentName: string; subagent: Subagent<any> }
|
|
10
18
|
export type RegisteredCronJob = { pluginName: string; localId: string; globalId: string; job: CronJob }
|
|
11
19
|
export type RegisteredSkillEntry = { pluginName: string; localName: string; skill: PluginSkill }
|
|
12
20
|
export type RegisteredSkillDir = { pluginName: string; path: string }
|
|
21
|
+
export type RegisteredDoctorCheck = {
|
|
22
|
+
pluginName: string
|
|
23
|
+
checkName: string
|
|
24
|
+
pluginConfig: unknown
|
|
25
|
+
logger: PluginLogger
|
|
26
|
+
check: PluginDoctorCheck
|
|
27
|
+
}
|
|
13
28
|
|
|
14
29
|
export type PluginRegistry = {
|
|
15
30
|
tools: RegisteredTool[]
|
|
@@ -17,6 +32,7 @@ export type PluginRegistry = {
|
|
|
17
32
|
cronJobs: RegisteredCronJob[]
|
|
18
33
|
skills: RegisteredSkillEntry[]
|
|
19
34
|
skillsDirs: RegisteredSkillDir[]
|
|
35
|
+
doctorChecks: RegisteredDoctorCheck[]
|
|
20
36
|
}
|
|
21
37
|
|
|
22
38
|
export type RegisterContributionsOptions = {
|
|
@@ -26,6 +42,7 @@ export type RegisterContributionsOptions = {
|
|
|
26
42
|
registry: PluginRegistry
|
|
27
43
|
hooks: HookBus
|
|
28
44
|
agentDir: string
|
|
45
|
+
pluginConfig: unknown
|
|
29
46
|
}
|
|
30
47
|
|
|
31
48
|
export function buildPluginCronGlobalId(pluginName: string, localId: string): string {
|
|
@@ -33,7 +50,7 @@ export function buildPluginCronGlobalId(pluginName: string, localId: string): st
|
|
|
33
50
|
}
|
|
34
51
|
|
|
35
52
|
export function registerContributions(opts: RegisterContributionsOptions): void {
|
|
36
|
-
const { pluginName, logger, exports: ex, registry, hooks, agentDir } = opts
|
|
53
|
+
const { pluginName, logger, exports: ex, registry, hooks, agentDir, pluginConfig } = opts
|
|
37
54
|
|
|
38
55
|
if (ex.tools) {
|
|
39
56
|
for (const [toolName, tool] of Object.entries(ex.tools)) {
|
|
@@ -99,6 +116,17 @@ export function registerContributions(opts: RegisterContributionsOptions): void
|
|
|
99
116
|
if (ex.hooks) {
|
|
100
117
|
hooks.registerAll(pluginName, agentDir, logger, ex.hooks)
|
|
101
118
|
}
|
|
119
|
+
|
|
120
|
+
if (ex.doctorChecks) {
|
|
121
|
+
for (const [checkName, check] of Object.entries(ex.doctorChecks)) {
|
|
122
|
+
assertNotEmpty('doctor check name', checkName, pluginName)
|
|
123
|
+
const conflict = registry.doctorChecks.find((c) => c.pluginName === pluginName && c.checkName === checkName)
|
|
124
|
+
if (conflict) {
|
|
125
|
+
throw new Error(`plugin ${pluginName}: doctor check "${checkName}" already registered`)
|
|
126
|
+
}
|
|
127
|
+
registry.doctorChecks.push({ pluginName, checkName, pluginConfig, logger, check })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
102
130
|
}
|
|
103
131
|
|
|
104
132
|
export function discardRegistrationsBy(pluginName: string, registry: PluginRegistry, hooks: HookBus): void {
|
|
@@ -107,11 +135,12 @@ export function discardRegistrationsBy(pluginName: string, registry: PluginRegis
|
|
|
107
135
|
registry.cronJobs = registry.cronJobs.filter((j) => j.pluginName !== pluginName)
|
|
108
136
|
registry.skills = registry.skills.filter((s) => s.pluginName !== pluginName)
|
|
109
137
|
registry.skillsDirs = registry.skillsDirs.filter((d) => d.pluginName !== pluginName)
|
|
138
|
+
registry.doctorChecks = registry.doctorChecks.filter((d) => d.pluginName !== pluginName)
|
|
110
139
|
hooks.unregisterAll(pluginName)
|
|
111
140
|
}
|
|
112
141
|
|
|
113
142
|
export function emptyRegistry(): PluginRegistry {
|
|
114
|
-
return { tools: [], subagents: [], cronJobs: [], skills: [], skillsDirs: [] }
|
|
143
|
+
return { tools: [], subagents: [], cronJobs: [], skills: [], skillsDirs: [], doctorChecks: [] }
|
|
115
144
|
}
|
|
116
145
|
|
|
117
146
|
function assertNotEmpty(kind: string, value: string, pluginName: string): void {
|