typeclaw 0.14.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -46,7 +46,7 @@
46
46
  "@mariozechner/pi-coding-agent": "^0.67.3",
47
47
  "@mariozechner/pi-tui": "^0.67.3",
48
48
  "@mozilla/readability": "^0.6.0",
49
- "agent-messenger": "2.18.0",
49
+ "agent-messenger": "2.19.0",
50
50
  "cheerio": "^1.2.0",
51
51
  "citty": "^0.2.2",
52
52
  "cron-parser": "^5.5.0",
@@ -393,7 +393,14 @@ export function createOutboundCallback(deps: {
393
393
  }
394
394
 
395
395
  try {
396
- const sent = await client.sendMessage(msg.chat, text, msg.thread ? { thread_id: msg.thread } : undefined)
396
+ const sendOptions: { thread_id?: string; reply_to?: string } = {}
397
+ if (msg.thread) sendOptions.thread_id = msg.thread
398
+ if (msg.replyTo?.externalMessageId) sendOptions.reply_to = msg.replyTo.externalMessageId
399
+ const sent = await client.sendMessage(
400
+ msg.chat,
401
+ text,
402
+ Object.keys(sendOptions).length > 0 ? sendOptions : undefined,
403
+ )
397
404
  logger.info(`[discord-bot] sent id=${sent.id} ${tag}`)
398
405
  return { ok: true }
399
406
  } catch (err) {
@@ -0,0 +1,239 @@
1
+ // KakaoTalk's LOCO protocol renders no rich text — bytes display verbatim, so
2
+ // the agent's Markdown (`**bold**`, `### heading`, fenced ```blocks```) leaks
3
+ // literal `*`/`#`/backtick noise into the chat. This strips the formatting
4
+ // markers and keeps the content. Mirrors telegram-bot-format.ts, but emits
5
+ // plain content instead of re-encoding to MarkdownV2. Links collapse to
6
+ // `label (url)` so the destination survives; list/quote markers stay (they
7
+ // read fine unrendered).
8
+
9
+ export function toKakaoPlainText(input: string): string {
10
+ // Pull fenced code out first so a `*` inside a block is not re-tokenized as
11
+ // italic.
12
+ const out: string[] = []
13
+ let i = 0
14
+ while (i < input.length) {
15
+ if (matchesAt(input, i, '```')) {
16
+ const fenceEnd = findFenceEnd(input, i + 3)
17
+ if (fenceEnd !== -1) {
18
+ out.push(renderFence(input.slice(i + 3, fenceEnd)))
19
+ i = fenceEnd + 3
20
+ continue
21
+ }
22
+ // Unterminated fence — strip the open backticks and render the rest
23
+ // inline so we never infinite-loop and never drop the tail.
24
+ out.push(renderInline(stripLeadingFence(input.slice(i + 3))))
25
+ break
26
+ }
27
+ const nextFence = input.indexOf('```', i)
28
+ const segmentEnd = nextFence === -1 ? input.length : nextFence
29
+ out.push(renderLines(input.slice(i, segmentEnd)))
30
+ i = segmentEnd
31
+ }
32
+ return out.join('')
33
+ }
34
+
35
+ function matchesAt(s: string, idx: number, needle: string): boolean {
36
+ return s.slice(idx, idx + needle.length) === needle
37
+ }
38
+
39
+ function findFenceEnd(s: string, start: number): number {
40
+ return s.indexOf('```', start)
41
+ }
42
+
43
+ function stripLeadingFence(inner: string): string {
44
+ // Drop an optional language hint and the newline after an opening fence.
45
+ const newline = inner.indexOf('\n')
46
+ if (newline === -1) return inner
47
+ const candidate = inner.slice(0, newline).trim()
48
+ if (candidate === '' || /^[A-Za-z0-9_+\-.]+$/.test(candidate)) {
49
+ return inner.slice(newline + 1)
50
+ }
51
+ return inner
52
+ }
53
+
54
+ function renderFence(inner: string): string {
55
+ // Keep the code body verbatim, drop the fences and any language hint.
56
+ let body = inner
57
+ const newline = inner.indexOf('\n')
58
+ if (newline !== -1) {
59
+ const candidate = inner.slice(0, newline).trim()
60
+ if (candidate === '' || /^[A-Za-z0-9_+\-.]+$/.test(candidate)) {
61
+ body = inner.slice(newline + 1)
62
+ }
63
+ }
64
+ if (body.endsWith('\n')) body = body.slice(0, -1)
65
+ return body
66
+ }
67
+
68
+ // Strip per-line block markers (heading hashes, blockquote arrows) before
69
+ // running the inline tokenizer on each line. List markers (`- `, `* `, `1.`)
70
+ // are left intact — they read fine as plain text and signal structure.
71
+ function renderLines(text: string): string {
72
+ const lines = text.split('\n')
73
+ const rendered = lines.map((line) => renderInline(stripBlockMarkers(line)))
74
+ return rendered.join('\n')
75
+ }
76
+
77
+ function stripBlockMarkers(line: string): string {
78
+ // `### heading` → `heading`; `> quote` → `quote`. Only acts on leading
79
+ // markers after optional indentation so mid-line `#`/`>` stay literal.
80
+ const heading = /^(\s*)#{1,6}\s+(.*)$/.exec(line)
81
+ if (heading !== null) return heading[1]! + heading[2]!
82
+ const quote = /^(\s*)>\s?(.*)$/.exec(line)
83
+ if (quote !== null) return quote[1]! + quote[2]!
84
+ return line
85
+ }
86
+
87
+ // Inline tokenizer. Recognizes (in priority order):
88
+ // 1. Inline code: `code` → code
89
+ // 2. Links: [text](url) → text (url)
90
+ // 3. Bold: **text** / __text__ → text
91
+ // 4. Strikethrough: ~~text~~ → text
92
+ // 5. Italic: *text* / _text_ → text
93
+ //
94
+ // Bold is checked before italic so `**` is not eaten as two italic markers.
95
+ // Word-boundary guards keep snake_case identifiers and `a*b` math from being
96
+ // mistaken for emphasis — the same rules the Telegram formatter uses.
97
+ function renderInline(text: string): string {
98
+ const out: string[] = []
99
+ let i = 0
100
+ while (i < text.length) {
101
+ const ch = text[i]!
102
+
103
+ if (ch === '`') {
104
+ const close = text.indexOf('`', i + 1)
105
+ if (close !== -1) {
106
+ out.push(text.slice(i + 1, close))
107
+ i = close + 1
108
+ continue
109
+ }
110
+ }
111
+
112
+ if (ch === '[') {
113
+ const link = parseLink(text, i)
114
+ if (link !== null) {
115
+ const label = renderInline(link.label)
116
+ out.push(link.url === '' ? label : `${label} (${link.url})`)
117
+ i = link.end
118
+ continue
119
+ }
120
+ }
121
+
122
+ if (ch === '*' && text[i + 1] === '*') {
123
+ const close = findClose(text, i + 2, '**')
124
+ if (close !== -1 && close > i + 2) {
125
+ out.push(renderInline(text.slice(i + 2, close)))
126
+ i = close + 2
127
+ continue
128
+ }
129
+ }
130
+ if (ch === '_' && text[i + 1] === '_' && !isWordChar(text[i - 1])) {
131
+ const close = findClose(text, i + 2, '__')
132
+ if (close !== -1 && close > i + 2 && !isWordChar(text[close + 2])) {
133
+ out.push(renderInline(text.slice(i + 2, close)))
134
+ i = close + 2
135
+ continue
136
+ }
137
+ }
138
+
139
+ if (ch === '~' && text[i + 1] === '~') {
140
+ const close = findClose(text, i + 2, '~~')
141
+ if (close !== -1 && close > i + 2) {
142
+ out.push(renderInline(text.slice(i + 2, close)))
143
+ i = close + 2
144
+ continue
145
+ }
146
+ }
147
+
148
+ if (ch === '*' && !isWordChar(text[i - 1])) {
149
+ const close = findInlineClose(text, i + 1, '*')
150
+ if (close !== -1 && !isWordChar(text[close + 1])) {
151
+ const inner = text.slice(i + 1, close)
152
+ if (inner !== '' && !/^\s|\s$/.test(inner)) {
153
+ out.push(renderInline(inner))
154
+ i = close + 1
155
+ continue
156
+ }
157
+ }
158
+ }
159
+ if (ch === '_' && !isWordChar(text[i - 1])) {
160
+ const close = findInlineClose(text, i + 1, '_')
161
+ if (close !== -1 && !isWordChar(text[close + 1])) {
162
+ const inner = text.slice(i + 1, close)
163
+ if (inner !== '' && !/^\s|\s$/.test(inner)) {
164
+ out.push(renderInline(inner))
165
+ i = close + 1
166
+ continue
167
+ }
168
+ }
169
+ }
170
+
171
+ out.push(ch)
172
+ i++
173
+ }
174
+ return out.join('')
175
+ }
176
+
177
+ function findClose(text: string, from: number, marker: string): number {
178
+ let i = from
179
+ while (i <= text.length - marker.length) {
180
+ if (text[i] === '\\') {
181
+ i += 2
182
+ continue
183
+ }
184
+ if (matchesAt(text, i, marker)) return i
185
+ i++
186
+ }
187
+ return -1
188
+ }
189
+
190
+ function findInlineClose(text: string, from: number, marker: string): number {
191
+ let i = from
192
+ while (i < text.length) {
193
+ if (text[i] === '\n') return -1
194
+ if (text[i] === '\\') {
195
+ i += 2
196
+ continue
197
+ }
198
+ if (matchesAt(text, i, marker)) return i
199
+ i++
200
+ }
201
+ return -1
202
+ }
203
+
204
+ function parseLink(text: string, start: number): { label: string; url: string; end: number } | null {
205
+ let i = start + 1
206
+ const labelStart = i
207
+ while (i < text.length) {
208
+ const c = text[i]!
209
+ if (c === '\\') {
210
+ i += 2
211
+ continue
212
+ }
213
+ if (c === ']') break
214
+ if (c === '\n') return null
215
+ i++
216
+ }
217
+ if (text[i] !== ']' || text[i + 1] !== '(') return null
218
+ const label = text.slice(labelStart, i)
219
+ const urlStart = i + 2
220
+ let j = urlStart
221
+ while (j < text.length) {
222
+ const c = text[j]!
223
+ if (c === '\\') {
224
+ j += 2
225
+ continue
226
+ }
227
+ if (c === ')') break
228
+ if (c === '(') return null
229
+ if (c === '\n') return null
230
+ j++
231
+ }
232
+ if (text[j] !== ')') return null
233
+ return { label, url: text.slice(urlStart, j), end: j + 1 }
234
+ }
235
+
236
+ function isWordChar(ch: string | undefined): boolean {
237
+ if (ch === undefined) return false
238
+ return /[A-Za-z0-9_]/.test(ch)
239
+ }
@@ -8,6 +8,7 @@ import {
8
8
  type KakaoMember,
9
9
  type KakaoMessage,
10
10
  type KakaoProfile,
11
+ type KakaoReplyTarget,
11
12
  type KakaoSendResult,
12
13
  type KakaoTalkListenerEventMap,
13
14
  type KakaoTalkPushEmoticonEvent,
@@ -15,7 +16,7 @@ import {
15
16
  } from 'agent-messenger/kakaotalk'
16
17
  import type { KakaoAccountCredentials, KakaoConfig, PendingLoginState } from 'agent-messenger/kakaotalk'
17
18
 
18
- import type { ChannelRouter } from '@/channels/router'
19
+ import { prependQuoteAnchor, type ChannelRouter } from '@/channels/router'
19
20
  import type { ChannelAdapterConfig } from '@/channels/schema'
20
21
  import type {
21
22
  ChannelHistoryMessage,
@@ -39,6 +40,7 @@ import { createKakaoAuthorResolver, type KakaoAuthorResolver } from './kakaotalk
39
40
  import { createKakaoChannelResolver, type KakaoChannelResolver } from './kakaotalk-channel-resolver'
40
41
  import { classifyInbound, type InboundDropReason } from './kakaotalk-classify'
41
42
  import { createFetchAttachmentCallback } from './kakaotalk-fetch-attachment'
43
+ import { toKakaoPlainText } from './kakaotalk-format'
42
44
 
43
45
  // Structural duck-type of the upstream KakaoTalkClient class. The upstream
44
46
  // type is a class with private fields, and TypeScript treats those
@@ -53,7 +55,7 @@ export interface KakaoTalkClient {
53
55
  ): Promise<this>
54
56
  getChats(options?: { all?: boolean; search?: string }): Promise<KakaoChat[]>
55
57
  getMessages(chatId: string, options?: { count?: number; from?: string }): Promise<KakaoMessage[]>
56
- sendMessage(chatId: string, text: string): Promise<KakaoSendResult>
58
+ sendMessage(chatId: string, text: string, options?: { replyTo?: KakaoReplyTarget }): Promise<KakaoSendResult>
57
59
  sendAttachment(
58
60
  chatId: string,
59
61
  data: Uint8Array | Buffer,
@@ -160,6 +162,11 @@ export type KakaotalkAdapter = {
160
162
 
161
163
  export const KAKAO_HISTORY_LIMIT_MAX = 200
162
164
 
165
+ // How far back to scan for a reply target's source message. Matches the upstream
166
+ // CLI's window; an anchored reply targets the message just answered, so the
167
+ // target is almost always near the head of this window.
168
+ const KAKAO_REPLY_LOOKUP_COUNT = 100
169
+
163
170
  function formatLabel(name: string | undefined, id: string, prefix = ''): string {
164
171
  if (name === undefined || name === '' || name === id) return id
165
172
  return `${prefix}${name}(${id})`
@@ -171,7 +178,7 @@ async function readAttachmentBuffer(path: string): Promise<Buffer> {
171
178
  }
172
179
 
173
180
  export function createOutboundCallback(deps: {
174
- client: Pick<KakaoTalkClient, 'sendMessage' | 'sendAttachment'>
181
+ client: Pick<KakaoTalkClient, 'sendMessage' | 'sendAttachment' | 'getMessages'>
175
182
  logger: KakaotalkAdapterLogger
176
183
  formatChannelTag: (workspace: string, chat: string) => Promise<string>
177
184
  readFile?: (path: string) => Promise<Buffer>
@@ -182,7 +189,7 @@ export function createOutboundCallback(deps: {
182
189
  if (msg.adapter !== 'kakaotalk') {
183
190
  return { ok: false, error: `unknown adapter: ${msg.adapter}` }
184
191
  }
185
- const text = msg.text ?? ''
192
+ const text = toKakaoPlainText(msg.text ?? '')
186
193
  const attachments = msg.attachments ?? []
187
194
  if (text === '' && attachments.length === 0) {
188
195
  return { ok: false, error: 'message has neither text nor attachments' }
@@ -221,8 +228,26 @@ export function createOutboundCallback(deps: {
221
228
  }
222
229
 
223
230
  if (text !== '') {
231
+ // KakaoTalk's native reply payload is built from the *source* message
232
+ // (author, original text, type), which the SDK does not derive from a
233
+ // bare log_id — we resolve it from recent history. If that lookup can't
234
+ // find the target (scrolled past the window, or the fetch failed), we
235
+ // degrade to the same blockquote anchor the router uses for quote-mode
236
+ // adapters, so the reply still visibly references the right message.
237
+ let outboundText = text
238
+ let replyTarget: KakaoReplyTarget | undefined
239
+ if (msg.replyTo !== undefined) {
240
+ replyTarget = await resolveKakaoReplyTarget(client, msg.chat, msg.replyTo.externalMessageId, logger)
241
+ if (replyTarget === undefined && msg.replyTo.source !== undefined) {
242
+ outboundText = prependQuoteAnchor(text, msg.replyTo.source)
243
+ }
244
+ }
224
245
  try {
225
- const result = await client.sendMessage(msg.chat, text)
246
+ const result = await client.sendMessage(
247
+ msg.chat,
248
+ outboundText,
249
+ replyTarget !== undefined ? { replyTo: replyTarget } : undefined,
250
+ )
226
251
  if (!result.success) {
227
252
  logger.error(`[kakaotalk] sendMessage status_code=${result.status_code} ${tag}`)
228
253
  return { ok: false, error: `kakaotalk send failed with status ${result.status_code}` }
@@ -239,6 +264,30 @@ export function createOutboundCallback(deps: {
239
264
  }
240
265
  }
241
266
 
267
+ // KakaoTalk replies need the full source message, not just its log_id. Resolve
268
+ // it from the chat's recent history (matching the upstream CLI's approach).
269
+ // Returns undefined when the target isn't in the fetched window or the fetch
270
+ // throws — the caller degrades to the blockquote fallback in that case.
271
+ async function resolveKakaoReplyTarget(
272
+ client: Pick<KakaoTalkClient, 'getMessages'>,
273
+ chatId: string,
274
+ externalMessageId: string,
275
+ logger: KakaotalkAdapterLogger,
276
+ ): Promise<KakaoReplyTarget | undefined> {
277
+ try {
278
+ const messages = await client.getMessages(chatId, { count: KAKAO_REPLY_LOOKUP_COUNT })
279
+ const target = messages.find((m) => m.log_id === externalMessageId)
280
+ if (target === undefined) {
281
+ logger.warn(`[kakaotalk] reply target log_id=${externalMessageId} not in last ${KAKAO_REPLY_LOOKUP_COUNT}`)
282
+ return undefined
283
+ }
284
+ return { log_id: target.log_id, author_id: target.author_id, message: target.message, type: target.type }
285
+ } catch (err) {
286
+ logger.warn(`[kakaotalk] reply target lookup failed: ${describe(err)}`)
287
+ return undefined
288
+ }
289
+ }
290
+
242
291
  export function createKakaoHistoryCallback(deps: {
243
292
  client: Pick<KakaoTalkClient, 'getMessages'>
244
293
  logger: KakaotalkAdapterLogger
@@ -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
@@ -1011,6 +1043,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1011
1043
  successfulChannelSends: 0,
1012
1044
  turnSeq: 0,
1013
1045
  successfulSendsAtTurnStart: 0,
1046
+ inFlightToolSends: new Map(),
1047
+ policyDeniedToolSendsThisTurn: new Map(),
1014
1048
  skippedTurn: null,
1015
1049
  pendingQuoteCandidate: null,
1016
1050
  recentEngagedPeerBotTurns: [],
@@ -1370,6 +1404,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1370
1404
  const successfulSendsBeforePrompt = live.successfulChannelSends
1371
1405
  live.turnSeq++
1372
1406
  live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
1407
+ live.policyDeniedToolSendsThisTurn.clear()
1373
1408
  await fireSessionTurnStart(live, text)
1374
1409
  try {
1375
1410
  await live.session.prompt(text)
@@ -1875,7 +1910,12 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1875
1910
  if (live && source === 'tool' && live.pendingQuoteCandidate !== null) {
1876
1911
  const quoteCandidate = refreshQuoteCandidate(live.pendingQuoteCandidate, live.contextBuffer)
1877
1912
  const anchor = decideQuoteAnchor(quoteCandidate, now(), options.configForAdapter(msg.adapter))
1878
- 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
+ }
1879
1919
  live.pendingQuoteCandidate = null
1880
1920
  }
1881
1921
  const text = normalizeSendText(msg.text)
@@ -1892,19 +1932,52 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1892
1932
  let priorLastSentText: string | undefined
1893
1933
  let reserved = false
1894
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
+ }
1895
1968
  // Tool-source send after `skip_response` for the same turn is a contract
1896
1969
  // violation: the model already committed to silence. Reject before any
1897
1970
  // state mutation so the model gets a clear error and the channel stays
1898
1971
  // silent. System-source sends (recovery, role-claim) are not affected.
1899
1972
  if (live.skippedTurn !== null && live.skippedTurn.turnSeq === live.turnSeq) {
1900
- return { ok: false, error: SKIP_RESPONSE_LOCK_ERROR, code: 'skip-locked' }
1973
+ return denyPolicyToolSend(SKIP_RESPONSE_LOCK_ERROR, 'skip-locked')
1901
1974
  }
1902
1975
  const currentCount = live.consecutiveSends.get(sendKey) ?? 0
1903
1976
  if (currentCount >= MAX_CHANNEL_SENDS_PER_TURN) {
1904
- return { ok: false, error: TURN_CAP_ERROR, code: 'turn-cap' }
1977
+ return denyPolicyToolSend(TURN_CAP_ERROR, 'turn-cap')
1905
1978
  }
1906
1979
  if (text !== undefined && live.lastSentText.get(sendKey) === text) {
1907
- return { ok: false, error: DUPLICATE_SEND_ERROR, code: 'duplicate' }
1980
+ return denyPolicyToolSend(DUPLICATE_SEND_ERROR, 'duplicate')
1908
1981
  }
1909
1982
  // Reserve the slot before awaiting. If the callback rejects we roll
1910
1983
  // back below; if it succeeds we keep the increment. The slot reserve
@@ -1915,6 +1988,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1915
1988
  priorLastSentText = live.lastSentText.get(sendKey)
1916
1989
  live.consecutiveSends.set(sendKey, currentCount + 1)
1917
1990
  if (text !== undefined) live.lastSentText.set(sendKey, text)
1991
+ live.inFlightToolSends.set(sendKey, (live.inFlightToolSends.get(sendKey) ?? 0) + 1)
1918
1992
  reserved = true
1919
1993
  }
1920
1994
 
@@ -1924,13 +1998,24 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1924
1998
  const snapshot = Array.from(callbacks)
1925
1999
  let lastError: string | undefined
1926
2000
  let delivered = false
1927
- for (const cb of snapshot) {
1928
- const result = await cb(msg)
1929
- if (result.ok) {
1930
- delivered = true
1931
- 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)
1932
2018
  }
1933
- lastError = result.error
1934
2019
  }
1935
2020
 
1936
2021
  if (!delivered) {
@@ -1950,6 +2035,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1950
2035
 
1951
2036
  if (live) {
1952
2037
  live.successfulChannelSends++
2038
+ live.policyDeniedToolSendsThisTurn.delete(sendKey)
1953
2039
  // Don't stop the heartbeat here: the agent may still be mid-turn and
1954
2040
  // about to send another reply. drain()'s finally block owns turn-end
1955
2041
  // stop. But Slack's adapter outbound callback explicitly clears
@@ -2480,12 +2566,7 @@ function formatAuthorLine(
2480
2566
  return `${stamp}${formatAuthorReference(adapter, authorId, authorName)} (${authorName})${tag}: ${text}`
2481
2567
  }
2482
2568
 
2483
- export type QuoteAnchorSource = {
2484
- adapter: AdapterId
2485
- authorId: string
2486
- authorName: string
2487
- text: string
2488
- }
2569
+ export type { QuoteAnchorSource } from './types'
2489
2570
 
2490
2571
  // Picks the right author syntax for the platform so prompts and rendered
2491
2572
  // quote anchors use the same form the user would type in that channel.
@@ -2557,6 +2638,7 @@ type QuoteAnchorBatchEntry = {
2557
2638
  authorName: string
2558
2639
  authorIsBot: boolean
2559
2640
  receivedAt: number
2641
+ externalMessageId: string
2560
2642
  }
2561
2643
 
2562
2644
  type QuoteAnchorObservedEntry = {
@@ -2566,10 +2648,18 @@ type QuoteAnchorObservedEntry = {
2566
2648
 
2567
2649
  export type QuoteAnchorCandidate = {
2568
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
2569
2654
  primaryReceivedAt: number
2570
2655
  hadInterveningObserved: boolean
2571
2656
  }
2572
2657
 
2658
+ export type QuoteAnchorTarget = {
2659
+ source: QuoteAnchorSource
2660
+ externalMessageId: string
2661
+ }
2662
+
2573
2663
  // Strips both current `[<Adapter> attachment #N: ...]` and legacy
2574
2664
  // `[<Adapter> message with ...]` placeholders that adapter
2575
2665
  // classifiers synthesize for non-text inbounds (KakaoTalk stickers,
@@ -2620,6 +2710,7 @@ export function captureQuoteCandidate(
2620
2710
  if (cleaned === '') return null
2621
2711
  return {
2622
2712
  source: { adapter, authorId: primary.authorId, authorName: primary.authorName, text: cleaned },
2713
+ externalMessageId: primary.externalMessageId,
2623
2714
  primaryReceivedAt: primary.receivedAt,
2624
2715
  hadInterveningObserved: hasInterveningObserved(primary.receivedAt, observed),
2625
2716
  }
@@ -2647,12 +2738,34 @@ export function decideQuoteAnchor(
2647
2738
  candidate: QuoteAnchorCandidate | null,
2648
2739
  _nowMs: number,
2649
2740
  adapterConfig: ChannelAdapterConfig | undefined,
2650
- ): QuoteAnchorSource | null {
2741
+ ): QuoteAnchorTarget | null {
2651
2742
  if (candidate === null) return null
2652
2743
  const config = adapterConfig?.quotedReply
2653
2744
  if (config !== undefined && config.enabled === false) return null
2654
2745
  if (!candidate.hadInterveningObserved) return null
2655
- 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'
2656
2769
  }
2657
2770
 
2658
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
  ]
@@ -38,7 +38,27 @@ export type BuildDockerfileOptions = {
38
38
  // self-heals: it spawns Xvfb (and exports DISPLAY) if the binary is on
39
39
  // PATH, and execs the agent directly otherwise. See APT_FEATURES.xvfb
40
40
  // below and `buildEntrypointShim`.
41
- const BASELINE_APT_PACKAGES = ['git', 'ca-certificates', 'curl', 'gnupg', 'iptables', 'util-linux'] as const
41
+ // `bubblewrap` ships the `bwrap(1)` setuid-less namespace sandboxer. It is
42
+ // included in baseline (not behind a toggle) because per-tool sandboxing of
43
+ // agent bash calls is a runtime concern resolved by the agent, not by the
44
+ // agent author. See `src/sandbox/` for the bwrap command builder, and
45
+ // `docs/internals/sandbox.mdx` for why bwrap is the right
46
+ // shape for per-call isolation inside an already-containerized agent. The
47
+ // outer container's `--security-opt seccomp=unconfined` (added in the same
48
+ // commit as this line; see `src/container/start.ts:planStart`) is what lets
49
+ // bwrap create user/pid/mount namespaces from inside Docker. Without that
50
+ // flag the seccomp default profile blocks `unshare(CLONE_NEWUSER)` and bwrap
51
+ // fails at startup. The two changes are load-bearing together — do not drop
52
+ // one without the other.
53
+ const BASELINE_APT_PACKAGES = [
54
+ 'git',
55
+ 'ca-certificates',
56
+ 'curl',
57
+ 'gnupg',
58
+ 'iptables',
59
+ 'util-linux',
60
+ 'bubblewrap',
61
+ ] as const
42
62
 
43
63
  // curl-impersonate is the only currently-working way to query DuckDuckGo from
44
64
  // a non-browser client on residential IPs in 2026. DDG fingerprints incoming
@@ -0,0 +1,35 @@
1
+ import { SandboxUnavailableError } from './errors'
2
+
3
+ // Cached because the binary cannot appear or disappear during a single
4
+ // process lifetime, and a probe per bash call is wasted work. Keyed by the
5
+ // resolved bwrap path so a test (or a consumer pinning a non-default path)
6
+ // re-probes instead of reading another path's cached result.
7
+ const availabilityCache = new Map<string, boolean>()
8
+
9
+ export async function ensureBwrapAvailable(options?: { bwrapPath?: string }): Promise<void> {
10
+ const bwrap = options?.bwrapPath ?? 'bwrap'
11
+ const cached = availabilityCache.get(bwrap)
12
+ if (cached === true) return
13
+ if (cached === false) throw new SandboxUnavailableError()
14
+
15
+ const available = await probe(bwrap)
16
+ availabilityCache.set(bwrap, available)
17
+ if (!available) throw new SandboxUnavailableError()
18
+ }
19
+
20
+ async function probe(bwrap: string): Promise<boolean> {
21
+ // Bun.spawn throws synchronously with ENOENT when the binary is not on
22
+ // PATH, rather than resolving with a non-zero exit code — so the
23
+ // "not installed" case lands in the catch, not in proc.exitCode.
24
+ try {
25
+ const proc = Bun.spawn([bwrap, '--version'], { stdout: 'ignore', stderr: 'ignore' })
26
+ await proc.exited
27
+ return proc.exitCode === 0
28
+ } catch {
29
+ return false
30
+ }
31
+ }
32
+
33
+ export function _resetBwrapAvailabilityCacheForTests(): void {
34
+ availabilityCache.clear()
35
+ }
@@ -0,0 +1,128 @@
1
+ import { SandboxPolicyError } from './errors'
2
+ import {
3
+ DEFAULT_SANDBOX_ENV,
4
+ type SandboxCommandFilter,
5
+ type SandboxEnvPolicy,
6
+ type SandboxMount,
7
+ type SandboxPolicy,
8
+ } from './policy'
9
+ import { formatCommand } from './quote'
10
+
11
+ export type SandboxedCommand = {
12
+ argv: string[]
13
+ commandString: string
14
+ }
15
+
16
+ // Pure: no I/O, no bwrap availability probe (that is `ensureBwrapAvailable`'s
17
+ // job). Given a bash command and a policy, returns the bwrap-wrapped argv plus
18
+ // a shell-quoted rendering of it. Knows nothing about subagents, origins, or
19
+ // the agent runtime — a consumer resolves a policy from whatever context it
20
+ // has and calls this. Throws SandboxPolicyError only when the consumer opted
21
+ // into the command-filter knobs and the command violates them.
22
+ export function buildSandboxedCommand(command: string, policy: SandboxPolicy = {}): SandboxedCommand {
23
+ if (policy.commandFilter !== undefined) {
24
+ applyCommandFilter(command, policy.commandFilter)
25
+ }
26
+ const argv = buildArgv(command, policy)
27
+ return { argv, commandString: formatCommand(argv) }
28
+ }
29
+
30
+ function buildArgv(command: string, policy: SandboxPolicy): string[] {
31
+ const bwrap = policy.bwrapPath ?? 'bwrap'
32
+ const argv: string[] = [bwrap, '--unshare-all']
33
+
34
+ if (policy.network === 'inherit') {
35
+ // --unshare-all already unshared the net namespace; --share-net rejoins
36
+ // the outer container's network. Other namespaces (user/pid/mount/ipc/
37
+ // uts/cgroup) stay unshared. Default ('none' / undefined) leaves the net
38
+ // namespace isolated — prompt-injected bash cannot exfiltrate over the
39
+ // network without the consumer explicitly opting in.
40
+ argv.push('--share-net')
41
+ }
42
+
43
+ const proc = policy.process ?? {}
44
+ if (proc.newSession !== false) {
45
+ // Drops the controlling terminal so the contained process cannot push
46
+ // input back into the agent's tty via TIOCSTI. Mandated by
47
+ // docs/internals/sandbox.mdx. Harmless for a one-shot `bash -c`.
48
+ argv.push('--new-session')
49
+ }
50
+ if (proc.dieWithParent !== false) {
51
+ argv.push('--die-with-parent')
52
+ }
53
+
54
+ argv.push('--clearenv')
55
+ for (const [key, value] of Object.entries(resolveEnv(policy.env))) {
56
+ argv.push('--setenv', key, value)
57
+ }
58
+
59
+ argv.push('--ro-bind', '/usr', '/usr', '--ro-bind', '/etc', '/etc', '--dev', '/dev', '--tmpfs', '/tmp')
60
+
61
+ if ((policy.proc ?? 'tmpfs') === 'tmpfs') {
62
+ // --tmpfs /proc, never --proc /proc (OrbStack's kernel blocks
63
+ // mount("proc",...) from user namespaces) and never --dev-bind /proc /proc
64
+ // (leaks the outer container's /proc/N/environ — including
65
+ // FIREWORKS_API_KEY — into the sandbox). See sandbox.mdx.
66
+ argv.push('--tmpfs', '/proc')
67
+ }
68
+
69
+ for (const mount of policy.mounts ?? []) {
70
+ appendMount(argv, mount)
71
+ }
72
+
73
+ if (policy.cwd !== undefined) {
74
+ argv.push('--chdir', policy.cwd)
75
+ }
76
+
77
+ argv.push('bash', '-c', command)
78
+ return argv
79
+ }
80
+
81
+ function appendMount(argv: string[], mount: SandboxMount): void {
82
+ switch (mount.type) {
83
+ case 'ro-bind':
84
+ argv.push('--ro-bind', mount.source, mount.dest)
85
+ return
86
+ case 'bind':
87
+ argv.push('--bind', mount.source, mount.dest)
88
+ return
89
+ case 'tmpfs':
90
+ argv.push('--tmpfs', mount.dest)
91
+ return
92
+ case 'dev':
93
+ argv.push('--dev', mount.dest)
94
+ return
95
+ }
96
+ }
97
+
98
+ function resolveEnv(env: SandboxEnvPolicy | undefined): Record<string, string> {
99
+ const resolved: Record<string, string> = { ...DEFAULT_SANDBOX_ENV, ...env?.set }
100
+ for (const key of env?.passthrough ?? []) {
101
+ const value = process.env[key]
102
+ if (value !== undefined) resolved[key] = value
103
+ }
104
+ return resolved
105
+ }
106
+
107
+ // Token-boundary match: the normalized command must equal a prefix exactly or
108
+ // start with `prefix + ' '`. Substring matching would let `git-evil ...` slip
109
+ // past a `git` prefix; this does not.
110
+ const ALLOWLIST_WHITESPACE = /\s+/g
111
+ const FORBIDDEN_METACHARS = /[;&|`$()<>\\\n]/
112
+
113
+ function applyCommandFilter(command: string, filter: SandboxCommandFilter): void {
114
+ if (filter.rejectShellMetacharacters === true && FORBIDDEN_METACHARS.test(command)) {
115
+ throw new SandboxPolicyError(
116
+ 'command contains a forbidden shell metacharacter. This policy only permits simple commands without ; & | ` $ ( ) < > \\ or newlines.',
117
+ )
118
+ }
119
+ if (filter.allowPrefixes !== undefined) {
120
+ const normalized = command.trim().replace(ALLOWLIST_WHITESPACE, ' ')
121
+ const matched = filter.allowPrefixes.some((p) => normalized === p || normalized.startsWith(`${p} `))
122
+ if (!matched) {
123
+ throw new SandboxPolicyError(
124
+ `command does not match any allowed prefix. Allowed: ${filter.allowPrefixes.join(', ')}`,
125
+ )
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,20 @@
1
+ export class SandboxUnavailableError extends Error {
2
+ override readonly name = 'SandboxUnavailableError'
3
+ constructor() {
4
+ super(
5
+ 'sandbox unavailable: bwrap binary not found on PATH. Refusing to run a command that requires sandboxing without the kernel boundary in place.',
6
+ )
7
+ }
8
+ }
9
+
10
+ // Raised by the optional command-filter knobs (allowPrefixes,
11
+ // rejectShellMetacharacters). These are consumer-opt-in restrictions layered
12
+ // ABOVE the always-on kernel containment, so a rejection here is a policy
13
+ // decision the consumer asked for — not a failure of the sandbox itself. The
14
+ // message is phrased for the model to read and self-correct from.
15
+ export class SandboxPolicyError extends Error {
16
+ override readonly name = 'SandboxPolicyError'
17
+ constructor(reason: string) {
18
+ super(`sandbox policy rejected command: ${reason}`)
19
+ }
20
+ }
@@ -0,0 +1,14 @@
1
+ export { buildSandboxedCommand, type SandboxedCommand } from './build'
2
+ export { ensureBwrapAvailable } from './availability'
3
+ export { formatCommand, shellQuote } from './quote'
4
+ export { SandboxPolicyError, SandboxUnavailableError } from './errors'
5
+ export {
6
+ DEFAULT_SANDBOX_ENV,
7
+ type SandboxCommandFilter,
8
+ type SandboxEnvPolicy,
9
+ type SandboxMount,
10
+ type SandboxNetwork,
11
+ type SandboxPolicy,
12
+ type SandboxProcessPolicy,
13
+ type SandboxProcStrategy,
14
+ } from './policy'
@@ -0,0 +1,47 @@
1
+ export type SandboxMount =
2
+ | { type: 'ro-bind'; source: string; dest: string }
3
+ | { type: 'bind'; source: string; dest: string }
4
+ | { type: 'tmpfs'; dest: string }
5
+ | { type: 'dev'; dest: string }
6
+
7
+ export type SandboxNetwork = 'none' | 'inherit'
8
+
9
+ export type SandboxProcStrategy = 'tmpfs' | 'none'
10
+
11
+ export type SandboxEnvPolicy = {
12
+ set?: Record<string, string>
13
+ passthrough?: string[]
14
+ }
15
+
16
+ export type SandboxCommandFilter = {
17
+ allowPrefixes?: string[]
18
+ rejectShellMetacharacters?: boolean
19
+ }
20
+
21
+ export type SandboxProcessPolicy = {
22
+ newSession?: boolean
23
+ dieWithParent?: boolean
24
+ }
25
+
26
+ export type SandboxPolicy = {
27
+ bwrapPath?: string
28
+ cwd?: string
29
+ mounts?: SandboxMount[]
30
+ network?: SandboxNetwork
31
+ env?: SandboxEnvPolicy
32
+ commandFilter?: SandboxCommandFilter
33
+ process?: SandboxProcessPolicy
34
+ proc?: SandboxProcStrategy
35
+ }
36
+
37
+ // The env the sandbox always re-introduces after `--clearenv`. Anything not
38
+ // listed here (or explicitly named in `env.set` / `env.passthrough` by the
39
+ // consumer) is invisible inside the sandbox. This is the load-bearing leak
40
+ // guard: the container env holds FIREWORKS_API_KEY and GH_TOKEN, and env
41
+ // inheritance is the single highest-risk exfil path for prompt-injected bash.
42
+ // HOME points at /tmp because the sandbox mounts /tmp as a fresh tmpfs.
43
+ export const DEFAULT_SANDBOX_ENV: Record<string, string> = {
44
+ PATH: '/usr/local/bin:/usr/bin:/bin',
45
+ HOME: '/tmp',
46
+ LANG: 'C.UTF-8',
47
+ }
@@ -0,0 +1,18 @@
1
+ // POSIX shell quoting for rendering a bwrap argv array into a single
2
+ // `bash -c`-safe string. Today's bash tool accepts a string `command` slot
3
+ // (`mutableArgs.command`), so the sandbox primitive renders its canonical
4
+ // argv into a quoted string the agent runtime can drop in unchanged.
5
+ //
6
+ // This is a local copy of the same helper in `src/update/index.ts`. It is
7
+ // deliberately not promoted to a shared module yet: two call sites do not
8
+ // justify the coupling, and this primitive is meant to stand alone with zero
9
+ // imports from the rest of the tree. Promote to `src/shared/shell.ts` only
10
+ // when a third independent consumer appears.
11
+ export function shellQuote(arg: string): string {
12
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(arg)) return arg
13
+ return `'${arg.replaceAll("'", "'\\''")}'`
14
+ }
15
+
16
+ export function formatCommand(argv: readonly string[]): string {
17
+ return argv.map(shellQuote).join(' ')
18
+ }
@@ -11,14 +11,16 @@ This means **you are messaging as a person, not as a bot.** Other participants s
11
11
 
12
12
  ## What KakaoTalk does NOT support
13
13
 
14
- If you produce any of the following, KakaoTalk will render it literally and the recipient will see the raw markup:
15
-
16
- - **Bold / italic / strikethrough** `**bold**` shows as `**bold**`. Drop the asterisks; emphasize with word choice or capitalization (sparingly).
17
- - **Headings** — `# H1`, `## H2`, `### H3` all render as raw `#` characters.
18
- - **Tables** pipe-delimited tables become a wall of `|` characters. Use bullet lists or short prose paragraphs instead.
19
- - **Code fences** — ``` blocks render as raw backticks. For short snippets, paste the code inline. For long snippets, summarize and offer to send it via another channel.
20
- - **Inline code** — `` `foo` `` renders as `` `foo` ``. Just write `foo`.
21
- - **Links with display text** — `[label](url)` becomes the literal string. Send the bare URL on its own; the KakaoTalk client will auto-link it.
14
+ KakaoTalk renders messages as plain text — it has no rich-text formatting. **Write plain text from the start.** The adapter strips common markdown as a safety net before sending (so an accidental `**bold**` won't leak literal asterisks), but treat that as a last-resort guard, not a license to write markdown: the strip removes _markers_, it cannot make formatting-dependent layouts like tables readable. Compose for a plain-text surface and you control the result; lean on the stripper and you get whatever falls out.
15
+
16
+ Specifically, do not rely on any of the following write the plain-text equivalent yourself:
17
+
18
+ - **Bold / italic / strikethrough** emphasize with word choice or capitalization (sparingly), not `**asterisks**`.
19
+ - **Headings** — `# H1`, `## H2`, `### H3` carry no visual weight here. Use a short label line or just lead with the point.
20
+ - **Tables** — the stripper cannot rescue a pipe-delimited table; it would collapse into an unreadable line. Use bullet lists or short prose paragraphs instead.
21
+ - **Code fences** — for short snippets, paste the code inline as plain text. For long snippets, summarize and offer to send it via another channel.
22
+ - **Inline code** — just write `foo`, no backticks.
23
+ - **Links with display text** — send the bare URL on its own line; the KakaoTalk client auto-links it. (A `[label](url)` that slips through is reduced to `label (url)`, but a bare URL reads cleaner.)
22
24
  - **Mentions** — there is no `@user` syntax that the protocol surfaces. Address people by name in the message body.
23
25
  - **Threads / replies-with-quote** — every message is a top-level chat post. There is no per-message reply UI.
24
26
  - **Outbound stickers / emoticons** — the KakaoTalk sticker store requires desktop-app purchase flows that the SDK does not replicate. Inbound stickers ARE surfaced (see below), but you cannot send one. If the user asks for a sticker, acknowledge the limit and offer text.
@@ -106,4 +108,4 @@ The adapter drops every inbound where `event.author_id` equals the logged-in acc
106
108
 
107
109
  ## When you cannot answer in KakaoTalk
108
110
 
109
- If the user asks you to do something the adapter cannot do (render markdown, post in a thread, send a sticker), say so plainly. Files are fine — those go through `attachments[]` as described above — but markdown rendering, threading, and stickers are real limits. Acknowledge the limit instead of silently dropping the request.
111
+ If the user asks you to do something the adapter cannot do (post in a thread, send a sticker, render a real table), say so plainly. Files are fine — those go through `attachments[]` as described above — but threading, stickers, and rich formatting are real limits. Markdown markers you emit get stripped to plain text automatically, so a stray `**` won't leak; the limit is that nothing renders as formatting, not that it crashes. Acknowledge the limit instead of silently dropping the request.
@@ -32,6 +32,7 @@
32
32
  "anthropic/claude-haiku-4-5",
33
33
  "anthropic/claude-sonnet-4-6",
34
34
  "anthropic/claude-opus-4-7",
35
+ "anthropic/claude-opus-4-8",
35
36
  "fireworks/accounts/fireworks/routers/kimi-k2p6-turbo",
36
37
  "zai/glm-4.5-air",
37
38
  "zai/glm-4.6",
@@ -59,6 +60,7 @@
59
60
  "anthropic/claude-haiku-4-5",
60
61
  "anthropic/claude-sonnet-4-6",
61
62
  "anthropic/claude-opus-4-7",
63
+ "anthropic/claude-opus-4-8",
62
64
  "fireworks/accounts/fireworks/routers/kimi-k2p6-turbo",
63
65
  "zai/glm-4.5-air",
64
66
  "zai/glm-4.6",