typeclaw 0.32.0 → 0.32.1
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/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/inspect.ts +5 -2
- package/src/config/config.ts +23 -11
- 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/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/skills/typeclaw-markdown-pdf/SKILL.md +64 -5
|
@@ -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()
|
package/src/cli/inspect.ts
CHANGED
|
@@ -299,9 +299,12 @@ async function clackSelectSession(
|
|
|
299
299
|
}
|
|
300
300
|
|
|
301
301
|
function itemLabel(item: ViewerItem): string {
|
|
302
|
+
// clack's select already draws a radio dot (●/○) per option; a leading status
|
|
303
|
+
// dot here doubled it into a confusing "● ○". Keep rows glyph-free; the live
|
|
304
|
+
// row uses ▸ (not a dot) to stay distinct from the radio cursor.
|
|
302
305
|
if (item.kind === 'logs') return `${c.dim('▤')} container logs`
|
|
303
|
-
if (item.kind === 'tui') return `${c.green('
|
|
304
|
-
return
|
|
306
|
+
if (item.kind === 'tui') return `${c.green('▸')} ${c.bold('live TUI')} ${sessionRowLabel(item.summary)}`
|
|
307
|
+
return sessionRowLabel(item.summary)
|
|
305
308
|
}
|
|
306
309
|
|
|
307
310
|
function itemHint(item: ViewerItem): { hint: string } {
|
package/src/config/config.ts
CHANGED
|
@@ -338,17 +338,29 @@ export const networkSchema = z
|
|
|
338
338
|
|
|
339
339
|
export type NetworkConfig = z.infer<typeof networkSchema>
|
|
340
340
|
|
|
341
|
-
// `realProc` opts the per-tool bwrap sandbox into the
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
341
|
+
// `realProc` opts the per-tool bwrap sandbox (src/sandbox/build.ts) into the
|
|
342
|
+
// stricter 'real-proc' /proc strategy: a fresh procfs scoped to a NEW PID
|
|
343
|
+
// namespace via `unshare --pid --fork --mount --mount-proc`. It adds full PID
|
|
344
|
+
// isolation (the agent runtime's pids are absent from the sandbox namespace),
|
|
345
|
+
// but needs CAP_SYS_ADMIN to mount proc — so `typeclaw start` grants the
|
|
346
|
+
// container `--cap-add=SYS_ADMIN` only when this is set.
|
|
347
|
+
//
|
|
348
|
+
// Default `false`, because external-package execution (`bunx agent-*`, `bun add
|
|
349
|
+
// <pkg>`, `bun run <pkg-bin>` — the core subagent workflow) no longer needs it:
|
|
350
|
+
// the default 'proc-bind' strategy `--ro-bind`s the container's already-real
|
|
351
|
+
// procfs into the sandbox with NO CAP_SYS_ADMIN, giving the runner's child a
|
|
352
|
+
// working /proc/self/{fd,maps} so it stops aborting with Bun's "NotDir". The
|
|
353
|
+
// agent runtime's /proc/N/environ (FIREWORKS_API_KEY) stays unreadable because
|
|
354
|
+
// bwrap's --unshare-user puts the sandbox in a child user namespace the kernel
|
|
355
|
+
// won't let read a parent-userns process's environ — verified at runtime by a
|
|
356
|
+
// probe before the strategy is selected (src/sandbox/availability.ts). Avoiding
|
|
357
|
+
// the broad CAP_SYS_ADMIN grant by default is a smaller blast radius than the
|
|
358
|
+
// non-secret PID metadata 'proc-bind' exposes — see docs/internals/sandbox.mdx.
|
|
359
|
+
//
|
|
360
|
+
// Set `true` only to add the PID-isolation posture on a host where the proc
|
|
361
|
+
// mount actually works (bare-metal Linux, Docker Desktop — NOT OrbStack, which
|
|
362
|
+
// rejects the mount even with the cap; there the runtime falls back to
|
|
363
|
+
// 'proc-bind' regardless). The cost is the CAP_SYS_ADMIN grant on the container.
|
|
352
364
|
export const sandboxSchema = z
|
|
353
365
|
.object({
|
|
354
366
|
realProc: z.boolean().default(false),
|
package/src/container/start.ts
CHANGED
|
@@ -514,16 +514,21 @@ export async function planStart({
|
|
|
514
514
|
}
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
-
// sandbox.realProc opts the per-tool bwrap sandbox
|
|
518
|
-
// strategy (src/sandbox/build.ts), which prefixes the sandbox with
|
|
517
|
+
// sandbox.realProc (default FALSE) opts into the per-tool bwrap sandbox's
|
|
518
|
+
// 'real-proc' strategy (src/sandbox/build.ts), which prefixes the sandbox with
|
|
519
519
|
// `unshare --pid --fork --mount --mount-proc`. Mounting a fresh procfs for the
|
|
520
520
|
// new PID namespace needs real CAP_SYS_ADMIN — seccomp=unconfined alone is not
|
|
521
521
|
// enough (it only unblocks the unshare/clone SYSCALLS; the kernel still
|
|
522
|
-
// rejects mount(2) of proc without the capability).
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
522
|
+
// rejects mount(2) of proc without the capability). So the grant is gated on
|
|
523
|
+
// the flag and is OFF by default: external-package execution (`bunx agent-*`)
|
|
524
|
+
// no longer needs it — the default 'proc-bind' strategy gives the runner real
|
|
525
|
+
// /proc without any outer capability (see docs/internals/sandbox.mdx). Setting
|
|
526
|
+
// realProc:true adds the stricter PID-isolation posture at the cost of this
|
|
527
|
+
// broad "new root" grant. The container-side strategy resolution still probes
|
|
528
|
+
// whether the mount actually works (canMountRealProc) and falls back to
|
|
529
|
+
// proc-bind on runtimes where the cap is a no-op (e.g. OrbStack), so this grant
|
|
530
|
+
// is necessary-but-not-sufficient by design. Placed before the image tag (like
|
|
531
|
+
// --cap-add=NET_ADMIN) so docker applies it at run time.
|
|
527
532
|
if (cfg.sandbox.realProc) {
|
|
528
533
|
runArgs.push('--cap-add=SYS_ADMIN')
|
|
529
534
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
// Dependency-free agent-folder resolution. Kept out of `src/init/index.ts` so
|
|
5
|
+
// the host CLI entry (`src/cli/index.ts`) can locate the agent folder at the
|
|
6
|
+
// dispatch boundary WITHOUT pulling in the heavy init barrel (which statically
|
|
7
|
+
// imports @/config, @/config/providers, @/container, @/secrets, @/tui — a
|
|
8
|
+
// ~190ms module graph). This module MUST NOT import from the init barrel,
|
|
9
|
+
// config, container, or plugin modules; keep the dependency direction one-way.
|
|
10
|
+
|
|
11
|
+
export const CONFIG_FILE = 'typeclaw.json'
|
|
12
|
+
|
|
13
|
+
export function isInitialized(dir: string): boolean {
|
|
14
|
+
return existsSync(join(dir, CONFIG_FILE))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Walks upward from `start` looking for the agent folder (the dir containing
|
|
18
|
+
// typeclaw.json). Returns the found dir, or null if nothing is found before
|
|
19
|
+
// the walk hits a stop boundary.
|
|
20
|
+
//
|
|
21
|
+
// Stop boundaries (whichever comes first, checked at every level):
|
|
22
|
+
// 1. The current dir contains typeclaw.json — return it.
|
|
23
|
+
// 2. The current dir contains .git — return null. A .git boundary marks a
|
|
24
|
+
// project root; refusing to cross it prevents accidentally picking up an
|
|
25
|
+
// unrelated parent project, and matches how typeclaw itself initializes
|
|
26
|
+
// one .git per agent folder.
|
|
27
|
+
// 3. We've reached the filesystem root — return null.
|
|
28
|
+
//
|
|
29
|
+
// The `.git` check fires AFTER the typeclaw.json check at the same level so
|
|
30
|
+
// that walking up from a subdir of the agent (e.g. `<agent>/workspace/`) still
|
|
31
|
+
// resolves to the agent root, even though the agent root itself contains both
|
|
32
|
+
// typeclaw.json and .git.
|
|
33
|
+
export function findAgentDir(start: string): string | null {
|
|
34
|
+
let dir = resolve(start)
|
|
35
|
+
const root = resolve(dir, '/')
|
|
36
|
+
while (true) {
|
|
37
|
+
if (existsSync(join(dir, CONFIG_FILE))) return dir
|
|
38
|
+
if (existsSync(join(dir, '.git'))) return null
|
|
39
|
+
if (dir === root) return null
|
|
40
|
+
const parent = dirname(dir)
|
|
41
|
+
if (parent === dir) return null
|
|
42
|
+
dir = parent
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/init/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { createTui } from '@/tui'
|
|
|
19
19
|
|
|
20
20
|
import { resolveBaseImageVersion, resolveScaffoldVersion } from './cli-version'
|
|
21
21
|
import { buildDockerfile, DOCKERFILE } from './dockerfile'
|
|
22
|
+
import { CONFIG_FILE, findAgentDir, isInitialized } from './find-agent-dir'
|
|
22
23
|
import { installGithubWebhooksEagerly, type EagerGithubWebhookInstallResult } from './github-webhook-install'
|
|
23
24
|
import { buildGitignore, GITIGNORE_FILE } from './gitignore'
|
|
24
25
|
import { buildHatchingPrompt } from './hatching'
|
|
@@ -35,7 +36,8 @@ export { GITKEEP_FILE, PACKAGES_DIR, PUBLIC_DIR } from './paths'
|
|
|
35
36
|
|
|
36
37
|
export { appendOrReplaceEnvKey, hasEnvKey, readEnvFile } from './env-file'
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
export { CONFIG_FILE, findAgentDir, isInitialized }
|
|
40
|
+
|
|
39
41
|
const CRON_FILE = 'cron.json'
|
|
40
42
|
const PACKAGE_FILE = 'package.json'
|
|
41
43
|
|
|
@@ -491,39 +493,6 @@ export function isDirectoryNonEmpty(dir: string): boolean {
|
|
|
491
493
|
}
|
|
492
494
|
}
|
|
493
495
|
|
|
494
|
-
export function isInitialized(dir: string): boolean {
|
|
495
|
-
return existsSync(join(dir, CONFIG_FILE))
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// Walks upward from `start` looking for the agent folder (the dir containing
|
|
499
|
-
// typeclaw.json). Returns the found dir, or null if nothing is found before
|
|
500
|
-
// the walk hits a stop boundary.
|
|
501
|
-
//
|
|
502
|
-
// Stop boundaries (whichever comes first, checked at every level):
|
|
503
|
-
// 1. The current dir contains typeclaw.json — return it.
|
|
504
|
-
// 2. The current dir contains .git — return null. A .git boundary marks a
|
|
505
|
-
// project root; refusing to cross it prevents accidentally picking up an
|
|
506
|
-
// unrelated parent project, and matches how typeclaw itself initializes
|
|
507
|
-
// one .git per agent folder.
|
|
508
|
-
// 3. We've reached the filesystem root — return null.
|
|
509
|
-
//
|
|
510
|
-
// The `.git` check fires AFTER the typeclaw.json check at the same level so
|
|
511
|
-
// that walking up from a subdir of the agent (e.g. `<agent>/workspace/`) still
|
|
512
|
-
// resolves to the agent root, even though the agent root itself contains both
|
|
513
|
-
// typeclaw.json and .git.
|
|
514
|
-
export function findAgentDir(start: string): string | null {
|
|
515
|
-
let dir = resolve(start)
|
|
516
|
-
const root = resolve(dir, '/')
|
|
517
|
-
while (true) {
|
|
518
|
-
if (existsSync(join(dir, CONFIG_FILE))) return dir
|
|
519
|
-
if (existsSync(join(dir, '.git'))) return null
|
|
520
|
-
if (dir === root) return null
|
|
521
|
-
const parent = dirname(dir)
|
|
522
|
-
if (parent === dir) return null
|
|
523
|
-
dir = parent
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
|
|
527
496
|
const HATCHED_COMMIT_SUBJECT = 'Hatched 🐣'
|
|
528
497
|
|
|
529
498
|
export async function isHatched(dir: string): Promise<boolean> {
|