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.
@@ -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()
@@ -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('')} ${c.bold('live TUI')} ${sessionRowLabel(item.summary)}`
304
- return `${c.dim('○')} ${sessionRowLabel(item.summary)}`
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 } {
@@ -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 'real-proc' strategy
342
- // (src/sandbox/build.ts): a fresh procfs scoped to a new PID namespace so
343
- // external-package runners (`bunx`, `bun add <pkg>`, `bun run <pkg-bin>`) get a
344
- // working /proc/self/{fd,maps} and stop aborting with Bun's "NotDir". Default
345
- // `false` keeps the universally-portable '--tmpfs /proc' profile, under which
346
- // sandboxed external-package execution is unsupported by design. Turning it on
347
- // makes `typeclaw start` grant the container CAP_SYS_ADMIN (required to mount
348
- // proc for the new PID namespace), which is a deliberate posture change on the
349
- // single-tenant outer boundarysee docs/internals/sandbox.mdx. PID isolation
350
- // and the /proc/N/environ leak guard are both preserved; the trade is the
351
- // CAP_SYS_ADMIN grant, not sandbox strength.
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),
@@ -514,16 +514,21 @@ export async function planStart({
514
514
  }
515
515
  }
516
516
 
517
- // sandbox.realProc opts the per-tool bwrap sandbox into the 'real-proc'
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). This is the deliberate
523
- // posture change documented in docs/internals/sandbox.mdx: the default keeps
524
- // the narrower seccomp-only profile, and the operator grants the broad
525
- // "new root" capability ONLY by opting into real-proc. Placed before the
526
- // image tag (like --cap-add=NET_ADMIN) so docker applies it at run time.
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
- const CONFIG_FILE = 'typeclaw.json'
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> {