typeclaw 0.32.0 → 0.33.0

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 (35) hide show
  1. package/package.json +1 -1
  2. package/scripts/verify-procbind-sandbox.sh +61 -0
  3. package/src/agent/multimodal/look-at.ts +7 -5
  4. package/src/agent/plugin-tools.ts +47 -12
  5. package/src/agent/session-origin.ts +15 -9
  6. package/src/agent/system-prompt.ts +6 -0
  7. package/src/agent/tools/channel-fetch-attachment.ts +8 -7
  8. package/src/agent/tools/channel-history.ts +2 -0
  9. package/src/bundled-plugins/github-cli-auth/gh-command.ts +267 -13
  10. package/src/bundled-plugins/reviewer/skills/code-review.ts +11 -9
  11. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +1 -0
  12. package/src/channels/adapters/slack-bot-reference.ts +9 -10
  13. package/src/channels/adapters/slack-bot.ts +29 -7
  14. package/src/channels/router.ts +89 -21
  15. package/src/cli/index.ts +42 -2
  16. package/src/cli/init.ts +267 -82
  17. package/src/cli/inspect.ts +5 -2
  18. package/src/cli/model.ts +5 -1
  19. package/src/cli/provider.ts +41 -10
  20. package/src/config/config.ts +23 -11
  21. package/src/config/providers.ts +304 -7
  22. package/src/container/start.ts +12 -7
  23. package/src/init/find-agent-dir.ts +44 -0
  24. package/src/init/index.ts +3 -34
  25. package/src/init/models-dev.ts +2 -0
  26. package/src/init/validate-api-key.ts +13 -0
  27. package/src/inspect/transcript-view.ts +33 -7
  28. package/src/sandbox/availability.ts +354 -2
  29. package/src/sandbox/build.ts +17 -7
  30. package/src/sandbox/index.ts +10 -1
  31. package/src/sandbox/policy.ts +27 -9
  32. package/src/secrets/oauth-xai.ts +342 -0
  33. package/src/secrets/storage.ts +2 -0
  34. package/src/skills/typeclaw-markdown-pdf/SKILL.md +64 -5
  35. package/typeclaw.schema.json +20 -2
@@ -54,6 +54,7 @@ const PROCESS_ENV_TARGETS: ReadonlyArray<string> = [
54
54
  'FIREWORKS_API_KEY',
55
55
  'OPENAI_API_KEY',
56
56
  'ANTHROPIC_API_KEY',
57
+ 'MINIMAX_API_KEY',
57
58
  'GOOGLE_API_KEY',
58
59
  'GEMINI_API_KEY',
59
60
  'AWS_ACCESS_KEY_ID',
@@ -16,7 +16,6 @@ export type SlackMessagePointer = {
16
16
  export async function enrichSlackReferenceContext(args: {
17
17
  text: string
18
18
  channelId: string
19
- threadTs?: string
20
19
  messageTs: string
21
20
  attachments?: readonly unknown[]
22
21
  fetchMessage: SlackReferenceFetch
@@ -25,17 +24,17 @@ export async function enrichSlackReferenceContext(args: {
25
24
  const sources: QuoteAnchorSource[] = []
26
25
  let kind: InboundReferenceContext['kind'] = 'link'
27
26
 
28
- if (args.threadTs !== undefined && args.threadTs !== args.messageTs) {
29
- const parent = await fetchSafely(args.fetchMessage, { channelId: args.channelId, messageTs: args.threadTs })
30
- if (parent !== null) {
31
- sources.push(toSource(parent))
32
- kind = 'reply'
33
- }
34
- }
35
-
27
+ // Slack `thread_ts` is thread MEMBERSHIP, not a "reply-to this message"
28
+ // signal: every message in a thread carries the same root ts, so deriving
29
+ // reply context from it attached the thread root as a quote anchor on every
30
+ // in-thread message — repeated once per buffered message in a turn, and
31
+ // re-attached on every turn for the life of the thread. Only explicit
32
+ // message shares and archive links below carry a genuine referenced-message
33
+ // signal. If Slack ever exposes a distinct referenced-message id, add a new
34
+ // path for it rather than reusing `thread_ts`.
36
35
  for (const source of extractSlackShareSources(args.attachments ?? [])) {
37
36
  sources.push(source)
38
- if (kind !== 'reply') kind = 'quote'
37
+ kind = 'quote'
39
38
  }
40
39
 
41
40
  const links = extractSlackMessageLinks(args.text).slice(0, args.linkLimit ?? 3)
@@ -32,7 +32,7 @@ import type {
32
32
  } from '@/channels/types'
33
33
  import { chunkMarkdown } from '@/markdown'
34
34
 
35
- import { createSlackAuthorResolver } from './slack-bot-author-resolver'
35
+ import { createSlackAuthorResolver, type SlackAuthorResolver } from './slack-bot-author-resolver'
36
36
  import { createSlackChannelResolver } from './slack-bot-channel-resolver'
37
37
  import {
38
38
  classifyInbound,
@@ -650,9 +650,10 @@ export function createSlackHistoryCallback(deps: {
650
650
  token: string
651
651
  logger: SlackBotAdapterLogger
652
652
  botUserIdRef: () => string | null
653
+ authorResolver?: SlackAuthorResolver
653
654
  fetchImpl?: typeof fetch
654
655
  }): HistoryCallback {
655
- const { token, logger, botUserIdRef } = deps
656
+ const { token, logger, botUserIdRef, authorResolver } = deps
656
657
  const fetchFn = deps.fetchImpl ?? fetch
657
658
  return async (args: FetchHistoryArgs): Promise<FetchHistoryResult> => {
658
659
  const limit = clampLimit(args.limit, SLACK_HISTORY_LIMIT_MAX)
@@ -687,6 +688,20 @@ export function createSlackHistoryCallback(deps: {
687
688
  const botUserId = botUserIdRef()
688
689
  const rawMessages = raw.messages ?? []
689
690
  const mapped = rawMessages.map((m) => mapSlackMessage(m, botUserId))
691
+ // History payloads carry no profile, so mapSlackMessage echoes the raw
692
+ // id into authorName; resolve it here so prompts show display names.
693
+ // Only msg.user authors are resolvable — bot_id-only messages have no
694
+ // users.info entry. The resolver caches/coalesces, so repeated authors
695
+ // cost one lookup each.
696
+ if (authorResolver !== undefined) {
697
+ await Promise.all(
698
+ mapped.map(async (message, index) => {
699
+ const userId = rawMessages[index]?.user
700
+ if (userId === undefined || userId === '') return
701
+ message.authorName = await authorResolver.resolve(userId)
702
+ }),
703
+ )
704
+ }
690
705
  // Slack's `conversations.history` returns newest-first; `replies`
691
706
  // returns oldest-first. Normalize to oldest-first so the agent always
692
707
  // reads chronological order regardless of scope.
@@ -905,7 +920,11 @@ export function createFetchAttachmentCallback(deps: {
905
920
  }
906
921
  }
907
922
 
908
- function createSlackReferenceFetch(deps: { token: string; fetchImpl: typeof fetch }) {
923
+ export function createSlackReferenceFetch(deps: {
924
+ token: string
925
+ fetchImpl: typeof fetch
926
+ authorResolver?: SlackAuthorResolver
927
+ }) {
909
928
  return async (channelId: string, messageTs: string) => {
910
929
  const url = new URL('https://slack.com/api/conversations.replies')
911
930
  url.searchParams.set('channel', channelId)
@@ -918,10 +937,13 @@ function createSlackReferenceFetch(deps: { token: string; fetchImpl: typeof fetc
918
937
  const messages = arrayField(body, 'messages')
919
938
  const first = recordValue(messages[0])
920
939
  if (first === null) return null
921
- const authorId = stringField(first, 'user') ?? stringField(first, 'bot_id')
940
+ const userId = stringField(first, 'user')
941
+ const authorId = userId ?? stringField(first, 'bot_id')
922
942
  const text = stringField(first, 'text')
923
943
  if (authorId === null || text === null) return null
924
- return { authorId, authorName: authorId, text }
944
+ const authorName =
945
+ userId !== null && deps.authorResolver !== undefined ? await deps.authorResolver.resolve(userId) : authorId
946
+ return { authorId, authorName, text }
925
947
  }
926
948
  }
927
949
 
@@ -981,6 +1003,7 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
981
1003
  token: options.token,
982
1004
  logger,
983
1005
  botUserIdRef: () => botUserId,
1006
+ authorResolver,
984
1007
  })
985
1008
 
986
1009
  const membershipResolver = createSlackMembershipResolver({
@@ -1105,10 +1128,9 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1105
1128
  const referenceResult = await enrichSlackReferenceContext({
1106
1129
  text: verdict.payload.text,
1107
1130
  channelId: event.channel,
1108
- ...(event.thread_ts !== undefined ? { threadTs: event.thread_ts } : {}),
1109
1131
  messageTs: event.ts,
1110
1132
  ...(slackAttachments !== undefined ? { attachments: slackAttachments } : {}),
1111
- fetchMessage: createSlackReferenceFetch({ token: options.token, fetchImpl }),
1133
+ fetchMessage: createSlackReferenceFetch({ token: options.token, fetchImpl, authorResolver }),
1112
1134
  })
1113
1135
  const enriched = {
1114
1136
  ...verdict.payload,
@@ -297,6 +297,8 @@ export class StaleLiveSessionError extends Error {
297
297
  export const RESOLVE_CHANNEL_NAMES_TIMEOUT_MS = 5_000
298
298
  export const FETCH_HISTORY_TIMEOUT_MS = 5_000
299
299
 
300
+ export const HISTORY_ATTACHMENT_LIMIT = 50
301
+
300
302
  // Watchdog over the whole session.idle hook chain. The drain loop awaits
301
303
  // `fireSessionIdle` between turns; a single hung plugin handler (e.g. a
302
304
  // memory-logger awaiting a network call that never resolves) wedges the
@@ -418,6 +420,8 @@ type ObservedInbound = {
418
420
  source: 'prefetch' | 'observed'
419
421
  }
420
422
 
423
+ type TimedAttachment = { ts: number; attachment: InboundAttachment }
424
+
421
425
  type LiveSession = {
422
426
  key: ChannelKey
423
427
  keyId: string
@@ -439,6 +443,17 @@ type LiveSession = {
439
443
  // and cleared when the turn ends, is what the lookup reads so a freshly-
440
444
  // arrived attachment stays resolvable for the whole turn it belongs to.
441
445
  currentTurnAttachments: readonly InboundAttachment[]
446
+ // Refs from an explicit channel_history look-back. A prior-turn attachment is
447
+ // replayed to the model as a text placeholder but its ref is gone from every
448
+ // turn-scoped queue above, so look_at/fetch can't resolve it; stashing the
449
+ // fetched refs here makes the same `attachment_id: N` resolvable. MUST be
450
+ // searched LAST so a live `#1` still wins over a historical `#1` (the
451
+ // newest-first collision rule lookupInboundAttachment documents). Bounded,
452
+ // never persisted, never exposes the ref to the model. historyTimedAttachments
453
+ // is the ts-tagged source of truth (ordered oldest→newest, deduped by id);
454
+ // historyAttachments is its flat projection consumed by the lookup helpers.
455
+ historyTimedAttachments: readonly TimedAttachment[]
456
+ historyAttachments: InboundAttachment[]
442
457
  draining: boolean
443
458
  debounceTimer: ReturnType<typeof setTimeout> | null
444
459
  typingTimer: ReturnType<typeof setInterval> | null
@@ -724,6 +739,10 @@ export type ChannelRouter = {
724
739
  getReviewState: (req: ReviewStateRequest) => Promise<ReviewStateResult>
725
740
  lookupInboundAttachment: (args: ChannelKey & { id: number }) => InboundAttachment | null
726
741
  listInboundAttachmentIds: (args: ChannelKey) => readonly number[]
742
+ // Stash refs from a channel_history fetch so prior-turn attachments stay
743
+ // resolvable by their placeholder id. Called by the channel_history tool
744
+ // after a successful fetch; no-op when the session is not live.
745
+ registerHistoryAttachments: (key: ChannelKey, messages: readonly ChannelHistoryMessage[]) => void
727
746
  // Execute a command by name against an existing live session, bypassing
728
747
  // the inbound classifier, engagement gate, debounce, and prompt queue.
729
748
  // Used by adapters that receive commands through a native surface
@@ -1407,6 +1426,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1407
1426
  pendingSystemReminders: [],
1408
1427
  contextBuffer: [],
1409
1428
  currentTurnAttachments: [],
1429
+ historyTimedAttachments: [],
1430
+ historyAttachments: [],
1410
1431
  draining: false,
1411
1432
  debounceTimer: null,
1412
1433
  typingTimer: null,
@@ -2778,7 +2799,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2778
2799
  if (hit !== undefined) return hit
2779
2800
  }
2780
2801
  }
2781
- return null
2802
+ return findAttachmentById(live.historyAttachments, args.id)
2782
2803
  }
2783
2804
 
2784
2805
  const listInboundAttachmentIds = (args: ChannelKey): readonly number[] => {
@@ -2789,9 +2810,36 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2789
2810
  for (const item of [...live.promptQueue, ...live.contextBuffer]) {
2790
2811
  for (const attachment of item.attachments ?? []) ids.add(attachment.id)
2791
2812
  }
2813
+ for (const attachment of live.historyAttachments) ids.add(attachment.id)
2792
2814
  return Array.from(ids).sort((a, b) => a - b)
2793
2815
  }
2794
2816
 
2817
+ const registerHistoryAttachments = (key: ChannelKey, messages: readonly ChannelHistoryMessage[]): void => {
2818
+ const live = liveSessions.get(channelKeyId(key))
2819
+ if (live === undefined) return
2820
+ const incoming: TimedAttachment[] = messages.flatMap((message) =>
2821
+ (message.attachments ?? []).map((attachment) => ({ ts: message.ts, attachment })),
2822
+ )
2823
+ if (incoming.length === 0) return
2824
+ // Order by message freshness, NOT append order: channel_history pages
2825
+ // OLDER messages via nextCursor, so a later call can deliver an OLDER ref.
2826
+ // findAttachmentById searches end-first, so the list MUST end with the
2827
+ // freshest ref or an older paged `#1` would shadow a newer one. Dedupe by
2828
+ // id keeping the freshest ts (a re-fetch of the same message is a no-op,
2829
+ // not a duplicate), sort ascending by ts, then keep the freshest LIMIT so
2830
+ // eviction drops the OLDEST refs, never newer ones.
2831
+ const byId = new Map<number, TimedAttachment>()
2832
+ for (const entry of [...live.historyTimedAttachments, ...incoming]) {
2833
+ const existing = byId.get(entry.attachment.id)
2834
+ if (existing === undefined || entry.ts >= existing.ts) byId.set(entry.attachment.id, entry)
2835
+ }
2836
+ const sorted = Array.from(byId.values()).sort((a, b) => a.ts - b.ts)
2837
+ const kept =
2838
+ sorted.length > HISTORY_ATTACHMENT_LIMIT ? sorted.slice(sorted.length - HISTORY_ATTACHMENT_LIMIT) : sorted
2839
+ live.historyTimedAttachments = kept
2840
+ live.historyAttachments = kept.map((entry) => entry.attachment)
2841
+ }
2842
+
2795
2843
  const send = async (msg: OutboundMessage, opts?: SendOptions): Promise<SendResult> => {
2796
2844
  const source: SendSource = opts?.source ?? 'tool'
2797
2845
  const callbacks = outboundCallbacks.get(msg.adapter)
@@ -3043,13 +3091,18 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3043
3091
  const candidate = recoverableAssistantText(live.session)
3044
3092
  if (candidate === null) {
3045
3093
  // No recoverable assistant prose: the turn ended with no usable reply.
3046
- // Two distinct shapes, handled differently (Option B):
3094
+ // Three distinct shapes, handled differently:
3047
3095
  //
3048
- // 1. The model THRASHED the send path this turn — it tried to send but
3049
- // every attempt was denied (skip-locked, or policy-denied/duplicate/
3050
- // cap, tracked on skipLockedSendTurn / policyDeniedToolSendsThisTurn).
3051
- // Re-prompting would just re-thrash, so skip retry and post the
3052
- // user-facing fallback once.
3096
+ // 1a. SKIP-LOCKED thrash the model called `skip_response` (committed to
3097
+ // silence) then tried to send; every attempt was denied skip-locked
3098
+ // (skipLockedSendTurn === turnSeq). Honor the silence decision: stay
3099
+ // silent, no fallback. Handled first, below.
3100
+ //
3101
+ // 1b. The model THRASHED the send path WITHOUT a skip commitment — denials
3102
+ // tracked on policyDeniedToolSendsThisTurn (duplicate/cap). In practice
3103
+ // these only accumulate after a real send landed, so the early return
3104
+ // above usually fires first; if one ever reaches here, re-prompting
3105
+ // would just re-thrash, so skip retry and post the fallback once.
3053
3106
  //
3054
3107
  // 2. The PURE reasoning-loop — no send was ever attempted; the model
3055
3108
  // burned its budget thinking and produced nothing (the canonical
@@ -3061,8 +3114,25 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3061
3114
  // The legitimate empty-state case (a TUI-only check before any user
3062
3115
  // prompt, no inbound this turn) is excluded: no batch means no real turn
3063
3116
  // to retry or apologize for — keep the historical silent bail there.
3064
- const attemptedSendThisTurn =
3065
- live.skipLockedSendTurn === live.turnSeq || live.policyDeniedToolSendsThisTurn.size > 0
3117
+ const skipLockedThisTurn = live.skipLockedSendTurn === live.turnSeq
3118
+ const attemptedSendThisTurn = skipLockedThisTurn || live.policyDeniedToolSendsThisTurn.size > 0
3119
+
3120
+ // Skip-locked thrash honors the skip with SILENCE, not the fallback. The
3121
+ // model called `skip_response` (committed to silence) then tried to send;
3122
+ // the send was denied skip-locked and a retry loop aborts the run, leaving
3123
+ // an `aborted` leaf whose reply text was a denied tool ARG — never
3124
+ // recoverable prose. EMPTY_TURN_FALLBACK_TEXT would be a false alarm here:
3125
+ // it reads as a system failure when the real state is the model's own
3126
+ // silence decision contradicted by a late reply. The pure turn-cap/duplicate
3127
+ // thrash below (no `skip_response`) never committed to silence, so it still
3128
+ // gets the fallback. Distinct log line keeps production signal.
3129
+ if (skipLockedThisTurn) {
3130
+ logger.warn(
3131
+ `[channels] ${live.keyId} skip_locked_send_thrash_suppressed ` +
3132
+ `denied_targets=${live.policyDeniedToolSendsThisTurn.size}`,
3133
+ )
3134
+ return
3135
+ }
3066
3136
 
3067
3137
  // Only a TRUNCATED assistant leaf (length/error/aborted) from a real
3068
3138
  // conversational turn is a degeneration worth retrying. A cold/empty turn
@@ -3693,6 +3763,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3693
3763
  getReviewState,
3694
3764
  lookupInboundAttachment,
3695
3765
  listInboundAttachmentIds,
3766
+ registerHistoryAttachments,
3696
3767
  executeCommand,
3697
3768
  getSelfAliases: computeSelfAliases,
3698
3769
  injectSubagentCompletionReminder,
@@ -3907,19 +3978,16 @@ function composeTurnPrompt(
3907
3978
  }
3908
3979
  parts.push('')
3909
3980
  }
3910
- // Only emit the `## Current message(s)` header when there is at least one
3911
- // queued inbound to live under it. A reminder-only wakeup (subagent
3912
- // completion firing while the prompt queue is empty) used to print the
3913
- // header with zero lines underneath; persona-rich models read the empty
3914
- // header as "there must be a current message addressed to me" and
3915
- // hallucinated content to reply to. The header is now batch-gated; the
3916
- // reminder block above and any observed context still render normally.
3981
+ // Emit the `## Current message(s)` header whenever the batch is non-empty.
3982
+ // It is batch-gated (a reminder-only wakeup with an empty promptQueue must
3983
+ // not print a header with zero lines under it persona-rich models read
3984
+ // the dangling header as a message they're failing to see and hallucinate a
3985
+ // reply). It must NOT also be gated on observed context: a turn carrying
3986
+ // only the current message then rendered the batch line bare, or flush under
3987
+ // the `## Recent context (not addressed to you …)` header — mislabeling the
3988
+ // one line the model is supposed to answer as context it should ignore.
3917
3989
  if (batch.length > 0) {
3918
- if (observed.length > 0) {
3919
- parts.push(
3920
- batch.length === 1 ? '## Current message (addressed to you)' : '## Current messages (addressed to you)',
3921
- )
3922
- }
3990
+ parts.push(batch.length === 1 ? '## Current message (addressed to you)' : '## Current messages (addressed to you)')
3923
3991
  for (const b of batch) {
3924
3992
  parts.push(formatInboundPromptLines(b, adapter))
3925
3993
  }
package/src/cli/index.ts CHANGED
@@ -3,8 +3,10 @@
3
3
  import { defineCommand, runMain } from 'citty'
4
4
 
5
5
  import { CLI_VERSION } from '../init/cli-version'
6
+ import { findAgentDir } from '../init/find-agent-dir'
7
+ import { runStartupMigrations } from '../migrations'
6
8
  import { BUILTIN_COMMAND_NAMES } from './builtins'
7
- import { dispatchPluginCommand, type PluginCommandDispatchOutcome } from './plugin-commands-dispatch'
9
+ import type { PluginCommandDispatchOutcome } from './plugin-commands-dispatch'
8
10
 
9
11
  const main = defineCommand({
10
12
  meta: {
@@ -40,12 +42,44 @@ const main = defineCommand({
40
42
  },
41
43
  })
42
44
 
43
- await runWithPluginDispatch()
45
+ // #673's v1->v2 secrets migration was wired only into the container-stage boot
46
+ // path (src/run/index.ts), so host CLI commands that read secrets.json directly
47
+ // (model/provider list -> tryReadProvidersSync -> v2-only parser) still hard-fail
48
+ // on a never-booted v1 folder. Run it once per host invocation here — at the
49
+ // dispatch boundary, NOT in the parse path, which would recreate the read-time
50
+ // shim #638 deliberately removed.
51
+ let hostStartupMigrationsDone = false
52
+
53
+ // `run` is the container stage and owns its own migration. Bare flag
54
+ // invocations (`--help`, `-h`, `--version`, `-v`, no command) are
55
+ // informational, exit before reading secrets, and must NOT rewrite secrets.json
56
+ // or emit migration warnings — so only a real subcommand triggers the migration.
57
+ function shouldRunHostStartupMigrations(commandName: string | undefined): boolean {
58
+ if (commandName === undefined || commandName === 'run') return false
59
+ return !commandName.startsWith('-')
60
+ }
61
+
62
+ function runHostStartupMigrationsOnce(commandName: string | undefined): void {
63
+ if (hostStartupMigrationsDone) return
64
+ hostStartupMigrationsDone = true
65
+ if (!shouldRunHostStartupMigrations(commandName)) return
66
+ const agentDir = findAgentDir(process.cwd())
67
+ if (agentDir === null) return
68
+ try {
69
+ runStartupMigrations(agentDir)
70
+ } catch (err) {
71
+ // runStartupMigrations isolates per-migration throws; this guards only the
72
+ // unexpected so a migration error can never block the host command itself.
73
+ console.warn(`[migration] host startup migration error: ${err instanceof Error ? err.message : String(err)}`)
74
+ }
75
+ }
44
76
 
45
77
  async function runWithPluginDispatch(): Promise<void> {
46
78
  const argv = process.argv.slice(2)
47
79
  const first = argv[0]
48
80
 
81
+ runHostStartupMigrationsOnce(first)
82
+
49
83
  if (first === '--help' || first === '-h') {
50
84
  // citty calls process.exit() after rendering help, so anything we print
51
85
  // AFTER `runMain(main)` is never reached. Print the plugin commands
@@ -64,6 +98,10 @@ async function runWithPluginDispatch(): Promise<void> {
64
98
  !first.startsWith('-') &&
65
99
  !BUILTIN_COMMAND_NAMES.includes(first as (typeof BUILTIN_COMMAND_NAMES)[number])
66
100
  ) {
101
+ // Lazy: the dispatch chain statically pulls in @/config, @/plugin, zod, and
102
+ // @/container (~190ms). Only plugin (non-builtin) commands need it, so we
103
+ // defer the import to keep builtin commands and bare flags fast.
104
+ const { dispatchPluginCommand } = await import('./plugin-commands-dispatch')
67
105
  const outcome = await dispatchPluginCommand({ name: first, rawArgs: argv.slice(1), cwd: process.cwd() })
68
106
  if (outcome.kind === 'dispatched') {
69
107
  process.exit(outcome.exitCode)
@@ -78,3 +116,5 @@ async function runWithPluginDispatch(): Promise<void> {
78
116
  }
79
117
 
80
118
  export type { PluginCommandDispatchOutcome }
119
+
120
+ await runWithPluginDispatch()