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.
- package/package.json +1 -1
- package/scripts/verify-procbind-sandbox.sh +61 -0
- package/src/agent/multimodal/look-at.ts +7 -5
- package/src/agent/plugin-tools.ts +47 -12
- package/src/agent/session-origin.ts +15 -9
- package/src/agent/system-prompt.ts +6 -0
- package/src/agent/tools/channel-fetch-attachment.ts +8 -7
- package/src/agent/tools/channel-history.ts +2 -0
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +267 -13
- package/src/bundled-plugins/reviewer/skills/code-review.ts +11 -9
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +1 -0
- package/src/channels/adapters/slack-bot-reference.ts +9 -10
- package/src/channels/adapters/slack-bot.ts +29 -7
- package/src/channels/router.ts +89 -21
- package/src/cli/index.ts +42 -2
- package/src/cli/init.ts +267 -82
- package/src/cli/inspect.ts +5 -2
- package/src/cli/model.ts +5 -1
- package/src/cli/provider.ts +41 -10
- package/src/config/config.ts +23 -11
- package/src/config/providers.ts +304 -7
- package/src/container/start.ts +12 -7
- package/src/init/find-agent-dir.ts +44 -0
- package/src/init/index.ts +3 -34
- package/src/init/models-dev.ts +2 -0
- package/src/init/validate-api-key.ts +13 -0
- package/src/inspect/transcript-view.ts +33 -7
- package/src/sandbox/availability.ts +354 -2
- package/src/sandbox/build.ts +17 -7
- package/src/sandbox/index.ts +10 -1
- package/src/sandbox/policy.ts +27 -9
- package/src/secrets/oauth-xai.ts +342 -0
- package/src/secrets/storage.ts +2 -0
- package/src/skills/typeclaw-markdown-pdf/SKILL.md +64 -5
- package/typeclaw.schema.json +20 -2
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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,
|
package/src/channels/router.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
3094
|
+
// Three distinct shapes, handled differently:
|
|
3047
3095
|
//
|
|
3048
|
-
//
|
|
3049
|
-
//
|
|
3050
|
-
//
|
|
3051
|
-
//
|
|
3052
|
-
//
|
|
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
|
|
3065
|
-
|
|
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
|
-
//
|
|
3911
|
-
//
|
|
3912
|
-
//
|
|
3913
|
-
// header
|
|
3914
|
-
//
|
|
3915
|
-
//
|
|
3916
|
-
//
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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()
|