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.
- package/package.json +2 -2
- package/src/agent/system-prompt.ts +11 -1
- package/src/agent/tools/skip-response.ts +24 -32
- package/src/agent/tools/spawn-subagent.ts +2 -0
- package/src/channels/adapters/discord-bot.ts +8 -1
- package/src/channels/adapters/github/inbound.ts +44 -5
- package/src/channels/adapters/github/index.ts +32 -0
- package/src/channels/adapters/kakaotalk-format.ts +239 -0
- package/src/channels/adapters/kakaotalk.ts +54 -5
- package/src/channels/adapters/telegram-bot.ts +11 -1
- package/src/channels/router.ts +152 -28
- package/src/channels/types.ts +22 -0
- package/src/config/providers.ts +17 -4
- package/src/container/start.ts +17 -0
- package/src/doctor/channel-checks.ts +328 -0
- package/src/doctor/checks.ts +2 -0
- package/src/init/dockerfile.ts +45 -8
- package/src/run/index.ts +18 -1
- package/src/sandbox/availability.ts +35 -0
- package/src/sandbox/build.ts +128 -0
- package/src/sandbox/errors.ts +20 -0
- package/src/sandbox/index.ts +14 -0
- package/src/sandbox/policy.ts +47 -0
- package/src/sandbox/quote.ts +18 -0
- package/src/secrets/claude-credentials-json.ts +129 -0
- package/src/secrets/export-claude-credentials-file.ts +279 -0
- package/src/secrets/index.ts +10 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +11 -9
- package/src/skills/typeclaw-claude-code/SKILL.md +5 -4
- package/src/skills/typeclaw-claude-code/references/auth-flow.md +35 -0
- package/typeclaw.schema.json +2 -0
|
@@ -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' } = {
|
|
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 }
|
package/src/channels/router.ts
CHANGED
|
@@ -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' —
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
// reply
|
|
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: '
|
|
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)
|
|
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
|
|
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
|
|
1977
|
+
return denyPolicyToolSend(TURN_CAP_ERROR, 'turn-cap')
|
|
1903
1978
|
}
|
|
1904
1979
|
if (text !== undefined && live.lastSentText.get(sendKey) === text) {
|
|
1905
|
-
return
|
|
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
|
-
|
|
1926
|
-
const
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
-
):
|
|
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 }
|
package/src/channels/types.ts
CHANGED
|
@@ -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 =
|
package/src/config/providers.ts
CHANGED
|
@@ -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-
|
|
201
|
-
// released
|
|
202
|
-
// Oct 1 2025). Anthropic's own model overview
|
|
203
|
-
//
|
|
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: {
|
package/src/container/start.ts
CHANGED
|
@@ -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
|
]
|