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.
Files changed (74) hide show
  1. package/README.md +16 -12
  2. package/auth.schema.json +238 -7
  3. package/package.json +1 -1
  4. package/secrets.schema.json +238 -7
  5. package/src/agent/auth.ts +19 -38
  6. package/src/agent/doctor.ts +173 -0
  7. package/src/agent/subagents.ts +24 -2
  8. package/src/agent/tools/channel-fetch-attachment.ts +6 -0
  9. package/src/agent/tools/channel-history.ts +10 -1
  10. package/src/agent/tools/channel-log.ts +32 -0
  11. package/src/agent/tools/channel-reply.ts +18 -1
  12. package/src/agent/tools/channel-send.ts +13 -1
  13. package/src/bundled-plugins/backup/README.md +81 -0
  14. package/src/bundled-plugins/backup/index.ts +209 -0
  15. package/src/bundled-plugins/backup/runner.ts +231 -0
  16. package/src/bundled-plugins/backup/subagents.ts +200 -0
  17. package/src/bundled-plugins/memory/index.ts +42 -1
  18. package/src/bundled-plugins/tool-result-cap/README.md +67 -0
  19. package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
  20. package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
  21. package/src/channels/adapters/kakaotalk.ts +25 -16
  22. package/src/channels/manager.ts +47 -38
  23. package/src/channels/router.ts +29 -0
  24. package/src/cli/channel.ts +3 -3
  25. package/src/cli/compose.ts +92 -1
  26. package/src/cli/doctor.ts +100 -0
  27. package/src/cli/index.ts +4 -0
  28. package/src/cli/init.ts +2 -1
  29. package/src/cli/ui.ts +11 -0
  30. package/src/compose/doctor.ts +141 -0
  31. package/src/compose/index.ts +8 -0
  32. package/src/compose/logs.ts +32 -19
  33. package/src/config/config.ts +31 -0
  34. package/src/container/log-colors.ts +75 -0
  35. package/src/container/log-timestamps.ts +84 -0
  36. package/src/container/logs.ts +71 -5
  37. package/src/container/start.ts +113 -9
  38. package/src/cron/consumer.ts +29 -7
  39. package/src/doctor/checks.ts +426 -0
  40. package/src/doctor/commit.ts +71 -0
  41. package/src/doctor/index.ts +287 -0
  42. package/src/doctor/plugin-bridge.ts +147 -0
  43. package/src/doctor/report.ts +142 -0
  44. package/src/doctor/types.ts +87 -0
  45. package/src/hostd/daemon.ts +28 -3
  46. package/src/hostd/protocol.ts +7 -0
  47. package/src/init/auto-upgrade.ts +368 -0
  48. package/src/init/cli-version.ts +81 -0
  49. package/src/init/dockerfile.ts +234 -25
  50. package/src/init/index.ts +141 -87
  51. package/src/init/kakaotalk-auth.ts +9 -3
  52. package/src/init/run-bun-install.ts +34 -0
  53. package/src/plugin/hooks.ts +32 -0
  54. package/src/plugin/index.ts +7 -0
  55. package/src/plugin/manager.ts +2 -0
  56. package/src/plugin/registry.ts +32 -3
  57. package/src/plugin/types.ts +65 -0
  58. package/src/run/bundled-plugins.ts +15 -0
  59. package/src/run/index.ts +19 -5
  60. package/src/secrets/defaults.ts +67 -0
  61. package/src/secrets/hydrate.ts +99 -0
  62. package/src/secrets/index.ts +6 -12
  63. package/src/secrets/kakao-store.ts +129 -0
  64. package/src/secrets/migrate-kakaotalk.ts +82 -0
  65. package/src/secrets/migrate.ts +5 -4
  66. package/src/secrets/resolve.ts +57 -0
  67. package/src/secrets/schema.ts +162 -42
  68. package/src/secrets/storage.ts +253 -47
  69. package/src/server/index.ts +103 -5
  70. package/src/shared/index.ts +3 -0
  71. package/src/shared/protocol.ts +22 -0
  72. package/src/skills/typeclaw-config/SKILL.md +48 -9
  73. package/typeclaw.schema.json +84 -0
  74. 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
- export type HatchRunner = (options: { cwd: string; port: number }) => Promise<HatchingResult>
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
- async function defaultRunHatching({ cwd, port }: { cwd: string; port: number }): Promise<HatchingResult> {
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 start({ cwd, preferredHostPort: port })
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 waitForAgent(`http://localhost:${hostPort}`, { timeoutMs: 30_000 })
272
+ await waitForAgentFn(`http://localhost:${hostPort}`, { timeoutMs: 30_000 })
234
273
 
235
- const tui = createTui({
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. `mounts`
346
- // defaults to `[]` in configSchema, and the bundled memory plugin owns its
347
- // own defaults in src/bundled-plugins/memory/index.ts re-emitting either here would
348
- // be duplicate noise the user has to maintain in sync with the source of
349
- // truth.
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: fileSpec,
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(join(root, DOCKERFILE), buildDockerfile(typeclawConfig.dockerfile), { flag: 'wx' }).catch(
438
- ignoreExists,
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 env var,
512
- // e.g. OPENAI_API_KEY or FIREWORKS_API_KEY) plus any channel adapter tokens.
513
- // The provider env var is resolved from KNOWN_PROVIDERS via the model ref,
514
- // so adding a new provider only requires touching providers.ts.
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. The
527
- // .env file still gets written for any channel adapter tokens.
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
- lines.push(`DISCORD_BOT_TOKEN=${discordBotToken}`)
602
+ channelTokens['discord-bot'] = { token: { value: discordBotToken } }
543
603
  }
544
604
  if (slackBotToken !== undefined && slackBotToken !== '') {
545
- lines.push(`SLACK_BOT_TOKEN=${slackBotToken}`)
605
+ channelTokens['slack-bot'] = { ...channelTokens['slack-bot'], botToken: { value: slackBotToken } }
546
606
  }
547
607
  if (slackAppToken !== undefined && slackAppToken !== '') {
548
- lines.push(`SLACK_APP_TOKEN=${slackAppToken}`)
608
+ channelTokens['slack-bot'] = { ...channelTokens['slack-bot'], appToken: { value: slackAppToken } }
549
609
  }
550
610
  if (telegramBotToken !== undefined && telegramBotToken !== '') {
551
- lines.push(`TELEGRAM_BOT_TOKEN=${telegramBotToken}`)
611
+ channelTokens['telegram-bot'] = { token: { value: telegramBotToken } }
552
612
  }
553
- // Always write .env even when empty so existing callers that read it
554
- // post-init (channel `add`, runtime startup) don't ENOENT-crash.
555
- const body = lines.length > 0 ? `${lines.join('\n')}\n` : ''
556
- await writeFile(join(root, SECRETS_FILE), body)
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
- // credentials file as "missing credentials, skip adapter", which silently
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
- await appendChannelSecrets(options.cwd, channelSecretsFromOptions(options))
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 { DISCORD_BOT_TOKEN: options.discordBotToken }
735
+ return { token: options.discordBotToken }
662
736
  case 'slack-bot':
663
- return { SLACK_BOT_TOKEN: options.slackBotToken, SLACK_APP_TOKEN: options.slackAppToken }
737
+ return { botToken: options.slackBotToken, appToken: options.slackAppToken }
664
738
  case 'telegram-bot':
665
- return { TELEGRAM_BOT_TOKEN: options.telegramBotToken }
739
+ return { token: options.telegramBotToken }
666
740
  case 'kakaotalk':
667
- // Credentials live in workspace/.agent-messenger/, not .env.
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
- // Appends only keys that are not already present in .env. We never rewrite
737
- // existing values: if the user has `SLACK_BOT_TOKEN=` left over from a manual
738
- // edit, we surface that as a hard error rather than overwrite the user's
739
- // hand-rolled value.
740
- //
741
- // `.env` parsing is intentionally line-based and dumb (matching dotenv's
742
- // minimum surface): trim, skip blanks/comments, split on the first `=`. We do
743
- // not unquote values because we only check for key presence.
744
- async function appendChannelSecrets(cwd: string, secrets: ChannelSecrets): Promise<void> {
745
- if (Object.keys(secrets).length === 0) return
746
-
747
- const path = join(cwd, SECRETS_FILE)
748
- let existing: string
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 presentKeys = parseEnvKeys(existing)
761
- for (const key of Object.keys(secrets)) {
762
- if (presentKeys.has(key)) {
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
- `${key} is already set in ${SECRETS_FILE}. Remove it before re-adding the channel, or edit the value by hand.`,
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
- // Ensure exactly one trailing newline before our appended block so the
770
- // resulting file remains POSIX-clean even if the user's editor stripped it.
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 { KakaoCredentialManager } from 'agent-messenger/kakaotalk'
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 KakaoCredentialManager(configDir)
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
+ }
@@ -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.
@@ -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,
@@ -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
  }
@@ -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 { PluginCronJob, PluginExports, PluginLogger, PluginSkill, Subagent, Tool } from './types'
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 {