typeclaw 0.13.0 → 0.15.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.
@@ -251,8 +251,12 @@ export function createOutboundCallback(deps: {
251
251
 
252
252
  try {
253
253
  const rendered = toTelegramMarkdownV2(text)
254
- const sendOptions: { message_thread_id?: number; parse_mode: 'MarkdownV2' } = { parse_mode: 'MarkdownV2' }
254
+ const sendOptions: { message_thread_id?: number; reply_to_message_id?: number; parse_mode: 'MarkdownV2' } = {
255
+ parse_mode: 'MarkdownV2',
256
+ }
255
257
  if (threadId !== undefined) sendOptions.message_thread_id = threadId
258
+ const replyToId = parseTelegramMessageId(msg.replyTo?.externalMessageId)
259
+ if (replyToId !== undefined) sendOptions.reply_to_message_id = replyToId
256
260
  const sent = await client.sendMessage(msg.chat, rendered, sendOptions)
257
261
  logger.info(`[telegram-bot] sent message_id=${sent.message_id} ${tag}`)
258
262
  return { ok: true }
@@ -270,6 +274,12 @@ function parseThreadId(thread: string | null | undefined): number | undefined {
270
274
  return Number.isFinite(n) ? n : undefined
271
275
  }
272
276
 
277
+ function parseTelegramMessageId(id: string | null | undefined): number | undefined {
278
+ if (id === null || id === undefined || id === '') return undefined
279
+ const n = Number(id)
280
+ return Number.isInteger(n) && n > 0 ? n : undefined
281
+ }
282
+
273
283
  type TelegramFileResponse = {
274
284
  ok: boolean
275
285
  result?: { file_id: string; file_unique_id: string; file_size?: number; file_path?: string }
@@ -45,7 +45,9 @@ import type {
45
45
  InboundMessage,
46
46
  OutboundCallback,
47
47
  OutboundMessage,
48
+ QuoteAnchorSource,
48
49
  ResolvedChannelNames,
50
+ SendErrorCode,
49
51
  SendResult,
50
52
  TypingCallback,
51
53
  } from './types'
@@ -98,6 +100,23 @@ export const SESSION_GC_INTERVAL_MS = 60 * 1000
98
100
  // Enforced inside router.send for `source: 'tool'` callers; system
99
101
  // recovery paths (`source: 'system'`) bypass.
100
102
  export const MAX_CHANNEL_SENDS_PER_TURN = 10
103
+ // Ceiling on tool-source channel sends that a same-turn router policy DENIED
104
+ // without delivering — `skip-locked`, `turn-cap`, or `duplicate`. Such denials
105
+ // return a soft error and do NOT increment `consecutiveSends`, so a model that
106
+ // ignores the denial and retries never trips `MAX_CHANNEL_SENDS_PER_TURN`.
107
+ // Both production livelocks had this shape: the model alternated a no-op
108
+ // `skip_response` with a denied `channel_reply` (~200-400x in one
109
+ // `session.prompt()`) — the interleaving defeated the byte-identical
110
+ // loop-guard's 5-in-a-row streak, and the denials bypassed the send cap. One
111
+ // turn was all `skip-locked`, the other all `duplicate` (byte-identical text).
112
+ // Past this ceiling we ABORT the run's AbortSignal (`agent.abort()`), which
113
+ // ends the turn on the next assistant stream. We can't just throw: the pi tool
114
+ // executor catches a tool's throw into an error result and the turn continues.
115
+ // Counted per send-target and only when NO concurrent reservation for that
116
+ // target is in flight, so a legitimate parallel send-burst (one winner + many
117
+ // same-tick duplicate/cap denials) is never mistaken for a loop. Reset at turn
118
+ // start alongside `turnSeq`.
119
+ export const MAX_POLICY_DENIED_CHANNEL_SENDS_PER_TURN = 3
101
120
  // Rolling window for outbound send-rate telemetry. 5s matches Discord's
102
121
  // rate-limit shape (5 msg / 5 s / channel) and comfortably covers Slack's
103
122
  // 1 msg/s sustained. The window is observational; exceeding the burst
@@ -347,6 +366,19 @@ type LiveSession = {
347
366
  // regardless of which order the model tried them in. Updated only at
348
367
  // turn start; reads against the live counter elsewhere are intentional.
349
368
  successfulSendsAtTurnStart: number
369
+ // Per-send-target count of tool-source sends with a reservation currently
370
+ // in flight (slot reserved, outbound callback not yet settled). Lets the
371
+ // policy-denial guard tell a legitimate parallel send-burst (denials that
372
+ // race a still-in-flight winner) from a sequential retry loop (denials with
373
+ // nothing in flight). Incremented at reservation, decremented in the
374
+ // callback-loop `finally` so an adapter throw can't strand a target.
375
+ inFlightToolSends: Map<string, number>
376
+ // Per-send-target count of policy-denied tool sends this turn that did NOT
377
+ // race an in-flight reservation. Drives the throw at
378
+ // `MAX_POLICY_DENIED_CHANNEL_SENDS_PER_TURN` that breaks the alternating-tool
379
+ // livelock the byte-identical loop-guard misses. Reset at turn start and
380
+ // cleared per-target on a successful delivery to that target.
381
+ policyDeniedToolSendsThisTurn: Map<string, number>
350
382
  // Stamped by `markTurnSkipped` (called from the `skip_response` tool)
351
383
  // with the current `turnSeq`. Read at the top of `validateChannelTurn`:
352
384
  // if it matches the just-completed turn, recovery is skipped entirely
@@ -498,13 +530,15 @@ export type ChannelRouter = {
498
530
  // turn cannot drop a future legitimate reply.
499
531
  //
500
532
  // Returns:
501
- // - 'recorded' — the live session was found and the skip was stamped
502
- // - 'send-already-happened' a tool-source channel send already landed
503
- // in this turn; the skip is refused (symmetric with
504
- // the send-after-skip lock in `send()`) so the model
505
- // cannot land a reply AND claim silence. The flag is
506
- // NOT stamped, so the turn proceeds as a normal
507
- // reply turn.
533
+ // - 'recorded' — silence-first: no send had landed this turn, so the
534
+ // skip was stamped and later tool-source sends are
535
+ // locked out via the send-after-skip guard in `send()`
536
+ // - 'recorded-after-send' reply-first: a tool-source channel send already
537
+ // landed this turn and the agent is now going quiet for
538
+ // the rest of it (the normal ack-then-wait pattern). The
539
+ // delivered reply stands; this skip posts nothing and is
540
+ // a terminal no-op. NOT stamped as a skipped turn (a
541
+ // reply already landed), and logged inline by the impl.
508
542
  // - 'no-live-session' — no matching channel session (e.g. tool fired
509
543
  // outside a channel origin); the tool should
510
544
  // still log the reason but cannot suppress.
@@ -513,7 +547,7 @@ export type ChannelRouter = {
513
547
  reason: string
514
548
  }) =>
515
549
  | { kind: 'recorded'; keyId: string }
516
- | { kind: 'send-already-happened'; keyId: string }
550
+ | { kind: 'recorded-after-send'; keyId: string }
517
551
  | { kind: 'no-live-session' }
518
552
  stop: () => Promise<void>
519
553
  liveCount: () => number
@@ -1009,6 +1043,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1009
1043
  successfulChannelSends: 0,
1010
1044
  turnSeq: 0,
1011
1045
  successfulSendsAtTurnStart: 0,
1046
+ inFlightToolSends: new Map(),
1047
+ policyDeniedToolSendsThisTurn: new Map(),
1012
1048
  skippedTurn: null,
1013
1049
  pendingQuoteCandidate: null,
1014
1050
  recentEngagedPeerBotTurns: [],
@@ -1368,6 +1404,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1368
1404
  const successfulSendsBeforePrompt = live.successfulChannelSends
1369
1405
  live.turnSeq++
1370
1406
  live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
1407
+ live.policyDeniedToolSendsThisTurn.clear()
1371
1408
  await fireSessionTurnStart(live, text)
1372
1409
  try {
1373
1410
  await live.session.prompt(text)
@@ -1873,7 +1910,12 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1873
1910
  if (live && source === 'tool' && live.pendingQuoteCandidate !== null) {
1874
1911
  const quoteCandidate = refreshQuoteCandidate(live.pendingQuoteCandidate, live.contextBuffer)
1875
1912
  const anchor = decideQuoteAnchor(quoteCandidate, now(), options.configForAdapter(msg.adapter))
1876
- if (anchor !== null) msg = { ...msg, text: prependQuoteAnchor(msg.text ?? '', anchor) }
1913
+ if (anchor !== null) {
1914
+ msg =
1915
+ resolveReplyRenderMode(msg) === 'native'
1916
+ ? { ...msg, replyTo: { externalMessageId: anchor.externalMessageId, source: anchor.source } }
1917
+ : { ...msg, text: prependQuoteAnchor(msg.text ?? '', anchor.source) }
1918
+ }
1877
1919
  live.pendingQuoteCandidate = null
1878
1920
  }
1879
1921
  const text = normalizeSendText(msg.text)
@@ -1890,19 +1932,52 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1890
1932
  let priorLastSentText: string | undefined
1891
1933
  let reserved = false
1892
1934
  if (live && source === 'tool') {
1935
+ // Every same-turn policy denial (skip-locked / turn-cap / duplicate)
1936
+ // returns a soft error and does NOT increment `consecutiveSends`, so a
1937
+ // model that ignores the denial and retries never trips the send cap. To
1938
+ // bound that loop we route all three through one tally that ABORTS the run
1939
+ // past the ceiling. The discriminator that keeps legitimate parallel
1940
+ // send-bursts soft: a denial only counts when NO reservation for the same
1941
+ // target is in flight. In a `Promise.all` burst the synchronous denials
1942
+ // all race the one in-flight winner, so they don't count; a sequential
1943
+ // retry loop has nothing in flight, so it does. See
1944
+ // `MAX_POLICY_DENIED_CHANNEL_SENDS_PER_TURN`.
1945
+ //
1946
+ // Why abort, not throw: pi-agent-core's tool executor catches a throw
1947
+ // from a tool's execute() and converts it into an `isError` tool result —
1948
+ // the turn would continue and the model could retry. The only thing that
1949
+ // actually ends an in-flight turn is aborting the run's AbortSignal:
1950
+ // `agent.abort()` flips it synchronously, then the NEXT assistant stream
1951
+ // (after this tool returns) sees the aborted signal and ends the turn with
1952
+ // stopReason 'aborted'. We must NOT call `session.abort()` here — it
1953
+ // `await`s `waitForIdle()`, which would deadlock waiting for the very run
1954
+ // this tool call belongs to. `agent.abort()` is the signal-only,
1955
+ // non-blocking variant. We still return the soft denial for this call.
1956
+ const denyPolicyToolSend = (error: string, code: SendErrorCode): SendResult => {
1957
+ if ((live.inFlightToolSends.get(sendKey) ?? 0) > 0) {
1958
+ return { ok: false, error, code }
1959
+ }
1960
+ const count = (live.policyDeniedToolSendsThisTurn.get(sendKey) ?? 0) + 1
1961
+ live.policyDeniedToolSendsThisTurn.set(sendKey, count)
1962
+ if (count >= MAX_POLICY_DENIED_CHANNEL_SENDS_PER_TURN) {
1963
+ logger.warn(`[channels] ${live.keyId}: aborting turn — ${count} policy-denied channel sends (last: ${code})`)
1964
+ if (live.session.agent.signal?.aborted !== true) live.session.agent.abort()
1965
+ }
1966
+ return { ok: false, error, code }
1967
+ }
1893
1968
  // Tool-source send after `skip_response` for the same turn is a contract
1894
1969
  // violation: the model already committed to silence. Reject before any
1895
1970
  // state mutation so the model gets a clear error and the channel stays
1896
1971
  // silent. System-source sends (recovery, role-claim) are not affected.
1897
1972
  if (live.skippedTurn !== null && live.skippedTurn.turnSeq === live.turnSeq) {
1898
- return { ok: false, error: SKIP_RESPONSE_LOCK_ERROR, code: 'skip-locked' }
1973
+ return denyPolicyToolSend(SKIP_RESPONSE_LOCK_ERROR, 'skip-locked')
1899
1974
  }
1900
1975
  const currentCount = live.consecutiveSends.get(sendKey) ?? 0
1901
1976
  if (currentCount >= MAX_CHANNEL_SENDS_PER_TURN) {
1902
- return { ok: false, error: TURN_CAP_ERROR, code: 'turn-cap' }
1977
+ return denyPolicyToolSend(TURN_CAP_ERROR, 'turn-cap')
1903
1978
  }
1904
1979
  if (text !== undefined && live.lastSentText.get(sendKey) === text) {
1905
- return { ok: false, error: DUPLICATE_SEND_ERROR, code: 'duplicate' }
1980
+ return denyPolicyToolSend(DUPLICATE_SEND_ERROR, 'duplicate')
1906
1981
  }
1907
1982
  // Reserve the slot before awaiting. If the callback rejects we roll
1908
1983
  // back below; if it succeeds we keep the increment. The slot reserve
@@ -1913,6 +1988,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1913
1988
  priorLastSentText = live.lastSentText.get(sendKey)
1914
1989
  live.consecutiveSends.set(sendKey, currentCount + 1)
1915
1990
  if (text !== undefined) live.lastSentText.set(sendKey, text)
1991
+ live.inFlightToolSends.set(sendKey, (live.inFlightToolSends.get(sendKey) ?? 0) + 1)
1916
1992
  reserved = true
1917
1993
  }
1918
1994
 
@@ -1922,13 +1998,24 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1922
1998
  const snapshot = Array.from(callbacks)
1923
1999
  let lastError: string | undefined
1924
2000
  let delivered = false
1925
- for (const cb of snapshot) {
1926
- const result = await cb(msg)
1927
- if (result.ok) {
1928
- delivered = true
1929
- break
2001
+ try {
2002
+ for (const cb of snapshot) {
2003
+ const result = await cb(msg)
2004
+ if (result.ok) {
2005
+ delivered = true
2006
+ break
2007
+ }
2008
+ lastError = result.error
2009
+ }
2010
+ } finally {
2011
+ // Clear the in-flight reservation even if a callback threw, so a flaky
2012
+ // adapter can never strand a target as permanently "in flight" and
2013
+ // disable the policy-denial guard for it.
2014
+ if (live && reserved) {
2015
+ const inFlight = (live.inFlightToolSends.get(sendKey) ?? 1) - 1
2016
+ if (inFlight <= 0) live.inFlightToolSends.delete(sendKey)
2017
+ else live.inFlightToolSends.set(sendKey, inFlight)
1930
2018
  }
1931
- lastError = result.error
1932
2019
  }
1933
2020
 
1934
2021
  if (!delivered) {
@@ -1948,6 +2035,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1948
2035
 
1949
2036
  if (live) {
1950
2037
  live.successfulChannelSends++
2038
+ live.policyDeniedToolSendsThisTurn.delete(sendKey)
1951
2039
  // Don't stop the heartbeat here: the agent may still be mid-turn and
1952
2040
  // about to send another reply. drain()'s finally block owns turn-end
1953
2041
  // stop. But Slack's adapter outbound callback explicitly clears
@@ -2254,13 +2342,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2254
2342
  reason: string
2255
2343
  }):
2256
2344
  | { kind: 'recorded'; keyId: string }
2257
- | { kind: 'send-already-happened'; keyId: string }
2345
+ | { kind: 'recorded-after-send'; keyId: string }
2258
2346
  | { kind: 'no-live-session' } => {
2259
2347
  for (const live of liveSessions.values()) {
2260
2348
  if (live.destroyed) continue
2261
2349
  if (live.sessionId !== args.parentSessionId) continue
2262
2350
  if (live.successfulChannelSends > live.successfulSendsAtTurnStart) {
2263
- return { kind: 'send-already-happened', keyId: live.keyId }
2351
+ // Reply-first skip ("acked, now going quiet"): accept as a terminal
2352
+ // no-op, never stamp `skippedTurn`. The delivered reply stands and must
2353
+ // not be suppressed, so stamping (which `validateChannelTurn` reads to
2354
+ // drop the turn) would be wrong; the send-after-skip lock only needs to
2355
+ // arm on the silence-first path. Rejecting this instead deadlocks the
2356
+ // agentic loop: denied a clean silent exit the model re-sends, gets
2357
+ // re-denied, and repeats until the per-turn send cap trips. Logged here
2358
+ // since `validateChannelTurn` won't see a `skippedTurn` for it.
2359
+ logger.info(`[channels] ${live.keyId} skip_after_send reason=${JSON.stringify(args.reason)}`)
2360
+ return { kind: 'recorded-after-send', keyId: live.keyId }
2264
2361
  }
2265
2362
  live.skippedTurn = { turnSeq: live.turnSeq, reason: args.reason }
2266
2363
  return { kind: 'recorded', keyId: live.keyId }
@@ -2469,12 +2566,7 @@ function formatAuthorLine(
2469
2566
  return `${stamp}${formatAuthorReference(adapter, authorId, authorName)} (${authorName})${tag}: ${text}`
2470
2567
  }
2471
2568
 
2472
- export type QuoteAnchorSource = {
2473
- adapter: AdapterId
2474
- authorId: string
2475
- authorName: string
2476
- text: string
2477
- }
2569
+ export type { QuoteAnchorSource } from './types'
2478
2570
 
2479
2571
  // Picks the right author syntax for the platform so prompts and rendered
2480
2572
  // quote anchors use the same form the user would type in that channel.
@@ -2546,6 +2638,7 @@ type QuoteAnchorBatchEntry = {
2546
2638
  authorName: string
2547
2639
  authorIsBot: boolean
2548
2640
  receivedAt: number
2641
+ externalMessageId: string
2549
2642
  }
2550
2643
 
2551
2644
  type QuoteAnchorObservedEntry = {
@@ -2555,10 +2648,18 @@ type QuoteAnchorObservedEntry = {
2555
2648
 
2556
2649
  export type QuoteAnchorCandidate = {
2557
2650
  source: QuoteAnchorSource
2651
+ // Native id of the primary inbound, so a native-reply adapter can point at
2652
+ // the exact message; the blockquote fallback ignores it.
2653
+ externalMessageId: string
2558
2654
  primaryReceivedAt: number
2559
2655
  hadInterveningObserved: boolean
2560
2656
  }
2561
2657
 
2658
+ export type QuoteAnchorTarget = {
2659
+ source: QuoteAnchorSource
2660
+ externalMessageId: string
2661
+ }
2662
+
2562
2663
  // Strips both current `[<Adapter> attachment #N: ...]` and legacy
2563
2664
  // `[<Adapter> message with ...]` placeholders that adapter
2564
2665
  // classifiers synthesize for non-text inbounds (KakaoTalk stickers,
@@ -2609,6 +2710,7 @@ export function captureQuoteCandidate(
2609
2710
  if (cleaned === '') return null
2610
2711
  return {
2611
2712
  source: { adapter, authorId: primary.authorId, authorName: primary.authorName, text: cleaned },
2713
+ externalMessageId: primary.externalMessageId,
2612
2714
  primaryReceivedAt: primary.receivedAt,
2613
2715
  hadInterveningObserved: hasInterveningObserved(primary.receivedAt, observed),
2614
2716
  }
@@ -2636,12 +2738,34 @@ export function decideQuoteAnchor(
2636
2738
  candidate: QuoteAnchorCandidate | null,
2637
2739
  _nowMs: number,
2638
2740
  adapterConfig: ChannelAdapterConfig | undefined,
2639
- ): QuoteAnchorSource | null {
2741
+ ): QuoteAnchorTarget | null {
2640
2742
  if (candidate === null) return null
2641
2743
  const config = adapterConfig?.quotedReply
2642
2744
  if (config !== undefined && config.enabled === false) return null
2643
2745
  if (!candidate.hadInterveningObserved) return null
2644
- return candidate.source
2746
+ return { source: candidate.source, externalMessageId: candidate.externalMessageId }
2747
+ }
2748
+
2749
+ export type ReplyRenderMode = 'native' | 'quote'
2750
+
2751
+ // Per-adapter, per-shape decision: can this exact outbound carry a native
2752
+ // platform reply, or must it degrade to the blockquote fallback? Conditional
2753
+ // because native support is not uniform within an adapter — Telegram's
2754
+ // `sendMessage` accepts `reply_to_message_id` but `sendDocument` does not, so
2755
+ // an attachment-only Telegram reply must quote; the same text-only restriction
2756
+ // holds for Discord (`message_reference` rides on the text send, file uploads
2757
+ // land bare) and KakaoTalk. Slack's primitive is `thread`, not a per-message
2758
+ // reply, so it stays quote; GitHub's PR-review reply already rides on `thread`.
2759
+ //
2760
+ // KakaoTalk is `native` here even though its reply payload can fail to resolve
2761
+ // at send time — the adapter degrades to the blockquote fallback itself using
2762
+ // `replyTo.source`, so the router still routes it down the native branch.
2763
+ const NATIVE_REPLY_TEXT_ADAPTERS = new Set<AdapterId>(['telegram-bot', 'discord-bot', 'kakaotalk'])
2764
+
2765
+ export function resolveReplyRenderMode(msg: OutboundMessage): ReplyRenderMode {
2766
+ const hasText = normalizeSendText(msg.text) !== undefined
2767
+ if (hasText && NATIVE_REPLY_TEXT_ADAPTERS.has(msg.adapter)) return 'native'
2768
+ return 'quote'
2645
2769
  }
2646
2770
 
2647
2771
  type Sliced = { kind: 'message'; message: ChannelHistoryMessage } | { kind: 'elision'; elidedCount: number }
@@ -126,6 +126,28 @@ export type OutboundMessage = {
126
126
  // `uploadFile` does not accept a content body or a thread id, see the
127
127
  // adapter for the workaround details.
128
128
  attachments?: OutboundAttachment[]
129
+ // Set by the router (native render mode + anchor fired) so an adapter can
130
+ // reply to the inbound it answers. Telegram/Discord consume `externalMessageId`;
131
+ // `quote`-mode adapters never see this (the router prepends the blockquote into
132
+ // `text` instead). `source` lets an adapter whose native primitive can fail at
133
+ // send time (KakaoTalk: payload built from a source message that may have
134
+ // scrolled out of history) degrade to the same blockquote fallback.
135
+ replyTo?: OutboundReplyTo
136
+ }
137
+
138
+ export type OutboundReplyTo = {
139
+ externalMessageId: string
140
+ source?: QuoteAnchorSource
141
+ }
142
+
143
+ // `adapter` selects the per-platform author-mention syntax in the blockquote
144
+ // fallback. Lives here (not router.ts) so adapters can reconstruct a native
145
+ // reply payload from the same shape the router renders quotes from.
146
+ export type QuoteAnchorSource = {
147
+ adapter: AdapterId
148
+ authorId: string
149
+ authorName: string
150
+ text: string
129
151
  }
130
152
 
131
153
  export type SendErrorCode =
@@ -197,10 +197,11 @@ export const KNOWN_PROVIDERS = {
197
197
  // anthropic`) before relying on the env-var path. Same rule applies to any
198
198
  // future dual-auth provider — keep the surprise in mind when expanding.
199
199
  //
200
- // Model lineup is the current GA tier as of 2026-04-16: Opus 4.7 (top,
201
- // released Apr 16 2026), Sonnet 4.6 (mid, Feb 5 2026), Haiku 4.5 (fast,
202
- // Oct 1 2025). Anthropic's own model overview lists these three as the
203
- // current recommended set and flags earlier Opus/Sonnet variants with
200
+ // Model lineup is the current GA tier as of 2026-05-29: Opus 4.8 (top,
201
+ // released May 2026), Opus 4.7 (prior top, Apr 16 2026), Sonnet 4.6 (mid,
202
+ // Feb 5 2026), Haiku 4.5 (fast, Oct 1 2025). Anthropic's own model overview
203
+ // lists the latest Opus/Sonnet/Haiku as the current recommended set and
204
+ // flags earlier Opus/Sonnet variants with
204
205
  // "Consider migrating to current models." Opus 4 / Sonnet 4 are deprecated
205
206
  // (retirement: Jun 15 2026); the 4.5/4.6 alternates remain Active but are
206
207
  // not the recommended path.
@@ -276,6 +277,18 @@ export const KNOWN_PROVIDERS = {
276
277
  contextWindow: 1000000,
277
278
  maxTokens: 128000,
278
279
  },
280
+ 'claude-opus-4-8': {
281
+ id: 'claude-opus-4-8',
282
+ name: 'Claude Opus 4.8',
283
+ api: 'anthropic-messages',
284
+ provider: 'anthropic',
285
+ baseUrl: 'https://api.anthropic.com',
286
+ reasoning: true,
287
+ input: ['text', 'image'],
288
+ cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
289
+ contextWindow: 1000000,
290
+ maxTokens: 128000,
291
+ },
279
292
  },
280
293
  },
281
294
  fireworks: {
@@ -464,12 +464,29 @@ export async function planStart({
464
464
  // misattribute to bot detection. 2g matches the Playwright/Puppeteer
465
465
  // canonical recommendation and is a memory cap, not an allocation (only
466
466
  // used pages count against the host).
467
+ // `seccomp=unconfined` lets `bwrap(1)` (installed in baseline; see
468
+ // BASELINE_APT_PACKAGES in src/init/dockerfile.ts) create user/pid/mount
469
+ // namespaces from inside the container. Docker's default seccomp profile
470
+ // rejects `unshare(CLONE_NEWUSER)` and `clone(CLONE_NEWUSER)` for
471
+ // non-privileged containers, which is the right default for multi-tenant
472
+ // hosts (Kubernetes nodes, CI runners) but wrong for typeclaw: the outer
473
+ // container is a single-tenant trust boundary — the user trusts everything
474
+ // inside it equally, the .env and agent folder are already mounted in —
475
+ // so the multi-tenant protections seccomp adds are not load-bearing for
476
+ // typeclaw's threat model. The per-tool sandbox bwrap builds for subagents
477
+ // IS the real boundary against prompt-injected commands; that boundary is
478
+ // what `--security-opt seccomp=unconfined` exists to enable. See
479
+ // `docs/internals/sandbox.mdx` for the full rationale including why
480
+ // `--cap-add=SYS_ADMIN` was rejected as an alternative (narrower in
481
+ // syscalls but strictly worse in capability semantics).
467
482
  const runArgs = [
468
483
  'run',
469
484
  '-d',
470
485
  '--name',
471
486
  containerName,
472
487
  '--shm-size=2g',
488
+ '--security-opt',
489
+ 'seccomp=unconfined',
473
490
  '-p',
474
491
  `${publishHost}:${hostPort}:${CONTAINER_PORT}`,
475
492
  ]