typeclaw 0.12.0 → 0.14.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.
Files changed (62) hide show
  1. package/package.json +1 -1
  2. package/scripts/dump-system-prompt.ts +12 -11
  3. package/src/agent/index.ts +15 -22
  4. package/src/agent/loop-guard.ts +170 -0
  5. package/src/agent/model-fallback.ts +2 -1
  6. package/src/agent/multimodal/index.ts +1 -1
  7. package/src/agent/multimodal/look-at.ts +118 -55
  8. package/src/agent/plugin-tools.ts +57 -0
  9. package/src/agent/subagents.ts +2 -1
  10. package/src/agent/system-prompt.ts +39 -26
  11. package/src/agent/tools/channel-fetch-attachment.ts +45 -16
  12. package/src/agent/tools/normalize-ref.ts +11 -0
  13. package/src/agent/tools/skip-response.ts +24 -32
  14. package/src/agent/tools/spawn-subagent.ts +2 -0
  15. package/src/bundled-plugins/reviewer/index.ts +11 -0
  16. package/src/bundled-plugins/reviewer/reviewer.ts +171 -0
  17. package/src/bundled-plugins/reviewer/skills/code-review.ts +73 -0
  18. package/src/bundled-plugins/reviewer/skills/general.ts +68 -0
  19. package/src/channels/adapters/discord-bot-classify.ts +32 -24
  20. package/src/channels/adapters/github/inbound.ts +63 -7
  21. package/src/channels/adapters/github/index.ts +32 -0
  22. package/src/channels/adapters/kakaotalk-attachment.ts +140 -133
  23. package/src/channels/adapters/kakaotalk-classify.ts +8 -1
  24. package/src/channels/adapters/kakaotalk.ts +19 -11
  25. package/src/channels/adapters/slack-bot-classify.ts +30 -14
  26. package/src/channels/adapters/slack-bot.ts +3 -2
  27. package/src/channels/adapters/telegram-bot-classify.ts +36 -13
  28. package/src/channels/adapters/telegram-bot.ts +3 -3
  29. package/src/channels/outbound-flood-filter.ts +57 -0
  30. package/src/channels/router.ts +114 -15
  31. package/src/channels/types.ts +52 -1
  32. package/src/cli/builtins.ts +1 -0
  33. package/src/cli/index.ts +1 -0
  34. package/src/cli/mount.ts +157 -0
  35. package/src/cli/update.ts +6 -4
  36. package/src/config/mounts-mutation.ts +161 -0
  37. package/src/doctor/channel-checks.ts +328 -0
  38. package/src/doctor/checks.ts +2 -0
  39. package/src/init/dockerfile.ts +24 -7
  40. package/src/init/hatching.ts +1 -1
  41. package/src/plugin/index.ts +6 -0
  42. package/src/plugin/load-skill.ts +99 -0
  43. package/src/run/bundled-plugins.ts +2 -0
  44. package/src/run/index.ts +31 -1
  45. package/src/secrets/claude-credentials-json.ts +129 -0
  46. package/src/secrets/codex-auth-json.ts +67 -0
  47. package/src/secrets/export-claude-credentials-file.ts +279 -0
  48. package/src/secrets/export-codex-auth-file.ts +243 -0
  49. package/src/secrets/index.ts +16 -0
  50. package/src/server/command-runner.ts +2 -1
  51. package/src/server/index.ts +3 -2
  52. package/src/shared/index.ts +7 -1
  53. package/src/shared/local-time.ts +32 -0
  54. package/src/skills/typeclaw-channel-github/SKILL.md +47 -13
  55. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +10 -11
  56. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +8 -0
  57. package/src/skills/typeclaw-claude-code/SKILL.md +5 -4
  58. package/src/skills/typeclaw-claude-code/references/auth-flow.md +35 -0
  59. package/src/skills/typeclaw-codex-cli/SKILL.md +2 -1
  60. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +22 -0
  61. package/src/skills/typeclaw-kaomoji/SKILL.md +116 -0
  62. package/src/update/index.ts +95 -26
@@ -0,0 +1,57 @@
1
+ export type OutboundFloodCheckResult = { ok: true } | { ok: false; reason: string }
2
+
3
+ const MIN_LENGTH = 40
4
+ const MAX_RUN = 30
5
+ const MIN_LONG_LENGTH = 80
6
+ const MIN_UNIQUE_RATIO = 0.05
7
+ const MAX_DOMINANCE = 0.9
8
+
9
+ export function checkOutboundFlood(text: string): OutboundFloodCheckResult {
10
+ if (text.length < MIN_LENGTH) return { ok: true }
11
+
12
+ const graphemes = Array.from(text.normalize('NFKC'))
13
+ if (graphemes.length < MIN_LENGTH) return { ok: true }
14
+
15
+ const longestRun = findLongestRun(graphemes)
16
+ if (longestRun >= MAX_RUN) return { ok: false, reason: `repeated-char-run:${longestRun}` }
17
+
18
+ if (graphemes.length < MIN_LONG_LENGTH) return { ok: true }
19
+
20
+ const counts = countGraphemes(graphemes)
21
+ const uniqueRatio = counts.size / graphemes.length
22
+ if (uniqueRatio < MIN_UNIQUE_RATIO) return { ok: false, reason: `low-unique-ratio:${uniqueRatio.toFixed(3)}` }
23
+
24
+ const dominance = maxValue(counts) / graphemes.length
25
+ if (dominance > MAX_DOMINANCE) return { ok: false, reason: `char-dominance:${dominance.toFixed(2)}` }
26
+
27
+ return { ok: true }
28
+ }
29
+
30
+ function findLongestRun(graphemes: readonly string[]): number {
31
+ if (graphemes.length === 0) return 0
32
+ let longest = 1
33
+ let current = 1
34
+ for (let i = 1; i < graphemes.length; i++) {
35
+ if (graphemes[i] === graphemes[i - 1]) {
36
+ current++
37
+ if (current > longest) longest = current
38
+ } else {
39
+ current = 1
40
+ }
41
+ }
42
+ return longest
43
+ }
44
+
45
+ function countGraphemes(graphemes: readonly string[]): Map<string, number> {
46
+ const counts = new Map<string, number>()
47
+ for (const grapheme of graphemes) counts.set(grapheme, (counts.get(grapheme) ?? 0) + 1)
48
+ return counts
49
+ }
50
+
51
+ function maxValue(counts: Map<string, number>): number {
52
+ let max = 0
53
+ for (const value of counts.values()) {
54
+ if (value > max) max = value
55
+ }
56
+ return max
57
+ }
@@ -3,7 +3,7 @@ import { basename } from 'node:path'
3
3
  import type { AssistantMessage } from '@mariozechner/pi-ai'
4
4
  import { SessionManager } from '@mariozechner/pi-coding-agent'
5
5
 
6
- import { createSession, type AgentSession } from '@/agent'
6
+ import { createSession, renderTurnTimeAnchor, type AgentSession } from '@/agent'
7
7
  import { subscribeProviderErrors } from '@/agent/provider-error'
8
8
  import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
9
9
  import { renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
@@ -21,6 +21,7 @@ import {
21
21
  type MembershipResolverResult,
22
22
  } from './membership'
23
23
  import { createMembershipCache, type MembershipCache } from './membership-cache'
24
+ import { checkOutboundFlood } from './outbound-flood-filter'
24
25
  import { updateParticipants } from './participants'
25
26
  import {
26
27
  channelsSessionsPath,
@@ -40,6 +41,7 @@ import type {
40
41
  FetchHistoryArgs,
41
42
  FetchHistoryResult,
42
43
  HistoryCallback,
44
+ InboundAttachment,
43
45
  InboundMessage,
44
46
  OutboundCallback,
45
47
  OutboundMessage,
@@ -106,6 +108,7 @@ export const SEND_RATE_WINDOW_MS = 5_000
106
108
  // send still emits a structured log line regardless of rate — this
107
109
  // constant only controls when the warning marker appears.
108
110
  export const SEND_RATE_WARN_THRESHOLD = 3
111
+ export const OUTBOUND_FLOOD_ERROR = 'outbound message denied: content looks like a repeated-character flood'
109
112
 
110
113
  /**
111
114
  * Maximum age of the last engaged inbound before the next inbound triggers a fresh session.
@@ -216,6 +219,7 @@ export type ConfigForAdapter = (adapter: ChannelKey['adapter']) => ChannelAdapte
216
219
 
217
220
  type QueuedInbound = {
218
221
  text: string
222
+ attachments?: readonly InboundAttachment[]
219
223
  authorId: string
220
224
  authorName: string
221
225
  authorIsBot: boolean
@@ -234,6 +238,7 @@ type QueuedInbound = {
234
238
 
235
239
  type ObservedInbound = {
236
240
  text: string
241
+ attachments?: readonly InboundAttachment[]
237
242
  authorId: string
238
243
  authorName: string
239
244
  authorIsBot: boolean
@@ -447,6 +452,8 @@ export type ChannelRouter = {
447
452
  registerFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
448
453
  unregisterFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
449
454
  fetchAttachment: (adapter: ChannelKey['adapter'], args: FetchAttachmentArgs) => Promise<FetchAttachmentResult>
455
+ lookupInboundAttachment: (args: ChannelKey & { id: number }) => InboundAttachment | null
456
+ listInboundAttachmentIds: (args: ChannelKey) => readonly number[]
450
457
  // Execute a command by name against an existing live session, bypassing
451
458
  // the inbound classifier, engagement gate, debounce, and prompt queue.
452
459
  // Used by adapters that receive commands through a native surface
@@ -491,13 +498,15 @@ export type ChannelRouter = {
491
498
  // turn cannot drop a future legitimate reply.
492
499
  //
493
500
  // Returns:
494
- // - 'recorded' — the live session was found and the skip was stamped
495
- // - 'send-already-happened' a tool-source channel send already landed
496
- // in this turn; the skip is refused (symmetric with
497
- // the send-after-skip lock in `send()`) so the model
498
- // cannot land a reply AND claim silence. The flag is
499
- // NOT stamped, so the turn proceeds as a normal
500
- // reply turn.
501
+ // - 'recorded' — silence-first: no send had landed this turn, so the
502
+ // skip was stamped and later tool-source sends are
503
+ // locked out via the send-after-skip guard in `send()`
504
+ // - 'recorded-after-send' reply-first: a tool-source channel send already
505
+ // landed this turn and the agent is now going quiet for
506
+ // the rest of it (the normal ack-then-wait pattern). The
507
+ // delivered reply stands; this skip posts nothing and is
508
+ // a terminal no-op. NOT stamped as a skipped turn (a
509
+ // reply already landed), and logged inline by the impl.
501
510
  // - 'no-live-session' — no matching channel session (e.g. tool fired
502
511
  // outside a channel origin); the tool should
503
512
  // still log the reason but cannot suppress.
@@ -506,7 +515,7 @@ export type ChannelRouter = {
506
515
  reason: string
507
516
  }) =>
508
517
  | { kind: 'recorded'; keyId: string }
509
- | { kind: 'send-already-happened'; keyId: string }
518
+ | { kind: 'recorded-after-send'; keyId: string }
510
519
  | { kind: 'no-live-session' }
511
520
  stop: () => Promise<void>
512
521
  liveCount: () => number
@@ -1635,6 +1644,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1635
1644
  const observe = (live: LiveSession, event: InboundMessage): void => {
1636
1645
  live.contextBuffer.push({
1637
1646
  text: event.text,
1647
+ ...(event.attachments !== undefined && event.attachments.length > 0 ? { attachments: event.attachments } : {}),
1638
1648
  authorId: event.authorId,
1639
1649
  authorName: event.authorName,
1640
1650
  authorIsBot: event.authorIsBot,
@@ -1650,6 +1660,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1650
1660
  const enqueue = (live: LiveSession, event: InboundMessage): void => {
1651
1661
  live.promptQueue.push({
1652
1662
  text: event.text,
1663
+ ...(event.attachments !== undefined && event.attachments.length > 0 ? { attachments: event.attachments } : {}),
1653
1664
  authorId: event.authorId,
1654
1665
  authorName: event.authorName,
1655
1666
  authorIsBot: event.authorIsBot,
@@ -1798,6 +1809,39 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1798
1809
  return lastError
1799
1810
  }
1800
1811
 
1812
+ const lookupInboundAttachment = (args: ChannelKey & { id: number }): InboundAttachment | null => {
1813
+ const live = liveSessions.get(channelKeyId(args))
1814
+ if (live === undefined) return null
1815
+ // Walk newest → oldest so that when an id collides across messages
1816
+ // (e.g. two photos in the same session each labelled `#1`) the agent's
1817
+ // `attachment_id: 1` always resolves to the CURRENT inbound's
1818
+ // attachment. promptQueue holds the about-to-be-delivered turn and
1819
+ // is therefore the freshest; within each list, append-order maps to
1820
+ // wall-clock order, so iterating in reverse gives recency.
1821
+ const haystacks: ReadonlyArray<ReadonlyArray<{ attachments?: readonly InboundAttachment[] }>> = [
1822
+ live.promptQueue,
1823
+ live.contextBuffer,
1824
+ ]
1825
+ for (const haystack of haystacks) {
1826
+ for (let i = haystack.length - 1; i >= 0; i--) {
1827
+ const item = haystack[i]
1828
+ const found = item?.attachments?.find((attachment) => attachment.id === args.id)
1829
+ if (found !== undefined) return found
1830
+ }
1831
+ }
1832
+ return null
1833
+ }
1834
+
1835
+ const listInboundAttachmentIds = (args: ChannelKey): readonly number[] => {
1836
+ const live = liveSessions.get(channelKeyId(args))
1837
+ if (live === undefined) return []
1838
+ const ids = new Set<number>()
1839
+ for (const item of [...live.promptQueue, ...live.contextBuffer]) {
1840
+ for (const attachment of item.attachments ?? []) ids.add(attachment.id)
1841
+ }
1842
+ return Array.from(ids).sort((a, b) => a - b)
1843
+ }
1844
+
1801
1845
  const send = async (msg: OutboundMessage, opts?: SendOptions): Promise<SendResult> => {
1802
1846
  const source: SendSource = opts?.source ?? 'tool'
1803
1847
  const callbacks = outboundCallbacks.get(msg.adapter)
@@ -1805,6 +1849,12 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1805
1849
  return { ok: false, error: `no adapter registered for "${msg.adapter}"`, code: 'no-adapter' }
1806
1850
  }
1807
1851
 
1852
+ const authoredText = normalizeSendText(msg.text)
1853
+ if (authoredText !== undefined) {
1854
+ const flood = checkOutboundFlood(authoredText)
1855
+ if (!flood.ok) return { ok: false, error: OUTBOUND_FLOOD_ERROR, code: 'outbound-flood' }
1856
+ }
1857
+
1808
1858
  const keyId = channelKeyId({
1809
1859
  adapter: msg.adapter,
1810
1860
  workspace: msg.workspace,
@@ -1982,6 +2032,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1982
2032
  return
1983
2033
  }
1984
2034
 
2035
+ if (isLikelyPlainTextChannelToolCall(assistantText)) {
2036
+ logger.warn(`[channels] ${live.keyId}: suppressed plain_text_channel_tool_call text_len=${assistantText.length}`)
2037
+ return
2038
+ }
2039
+
1985
2040
  // `source` distinguishes the two recovery shapes for log triage:
1986
2041
  // - 'leaf': the assistant message IS the leaf (existing behavior; model
1987
2042
  // ended its turn with text but forgot to call channel_reply).
@@ -2201,13 +2256,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2201
2256
  reason: string
2202
2257
  }):
2203
2258
  | { kind: 'recorded'; keyId: string }
2204
- | { kind: 'send-already-happened'; keyId: string }
2259
+ | { kind: 'recorded-after-send'; keyId: string }
2205
2260
  | { kind: 'no-live-session' } => {
2206
2261
  for (const live of liveSessions.values()) {
2207
2262
  if (live.destroyed) continue
2208
2263
  if (live.sessionId !== args.parentSessionId) continue
2209
2264
  if (live.successfulChannelSends > live.successfulSendsAtTurnStart) {
2210
- return { kind: 'send-already-happened', keyId: live.keyId }
2265
+ // Reply-first skip ("acked, now going quiet"): accept as a terminal
2266
+ // no-op, never stamp `skippedTurn`. The delivered reply stands and must
2267
+ // not be suppressed, so stamping (which `validateChannelTurn` reads to
2268
+ // drop the turn) would be wrong; the send-after-skip lock only needs to
2269
+ // arm on the silence-first path. Rejecting this instead deadlocks the
2270
+ // agentic loop: denied a clean silent exit the model re-sends, gets
2271
+ // re-denied, and repeats until the per-turn send cap trips. Logged here
2272
+ // since `validateChannelTurn` won't see a `skippedTurn` for it.
2273
+ logger.info(`[channels] ${live.keyId} skip_after_send reason=${JSON.stringify(args.reason)}`)
2274
+ return { kind: 'recorded-after-send', keyId: live.keyId }
2211
2275
  }
2212
2276
  live.skippedTurn = { turnSeq: live.turnSeq, reason: args.reason }
2213
2277
  return { kind: 'recorded', keyId: live.keyId }
@@ -2234,6 +2298,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2234
2298
  registerFetchAttachment,
2235
2299
  unregisterFetchAttachment,
2236
2300
  fetchAttachment,
2301
+ lookupInboundAttachment,
2302
+ listInboundAttachmentIds,
2237
2303
  executeCommand,
2238
2304
  getSelfAliases: computeSelfAliases,
2239
2305
  injectSubagentCompletionReminder,
@@ -2306,12 +2372,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2306
2372
  function composeTurnPrompt(
2307
2373
  observed: readonly ObservedInbound[],
2308
2374
  batch: readonly QueuedInbound[],
2309
- state: { adapter?: AdapterId; loopGuardActive: boolean; systemReminders?: readonly string[] } = {
2375
+ state: { adapter?: AdapterId; loopGuardActive: boolean; systemReminders?: readonly string[]; now?: Date } = {
2310
2376
  loopGuardActive: false,
2311
2377
  },
2312
2378
  ): string {
2313
2379
  const adapter = state.adapter ?? 'discord-bot'
2314
2380
  const parts: string[] = []
2381
+ parts.push(renderTurnTimeAnchor(state.now), '')
2315
2382
  // System reminders (subagent-completion wakeups today) lead the turn body
2316
2383
  // because they are typically what triggered the drain — when the prompt
2317
2384
  // queue is empty and the only thing in this iteration is a reminder, the
@@ -2503,18 +2570,20 @@ export type QuoteAnchorCandidate = {
2503
2570
  hadInterveningObserved: boolean
2504
2571
  }
2505
2572
 
2506
- // Strips `[<Adapter> message with ...]` placeholders that adapter
2573
+ // Strips both current `[<Adapter> attachment #N: ...]` and legacy
2574
+ // `[<Adapter> message with ...]` placeholders that adapter
2507
2575
  // classifiers synthesize for non-text inbounds (KakaoTalk stickers,
2508
2576
  // Slack/Discord/Telegram attachments). The quote anchor is a UX
2509
2577
  // affordance pointing the human at *their words* — quoting a sticker as
2510
- // `> Alice: [KakaoTalk message with sticker (sticker_ani) pack=... path=...]`
2578
+ // `> Alice: [KakaoTalk attachment #1: sticker name=...]`
2511
2579
  // is noise, and for mixed inbounds like `사진 [KakaoTalk message with
2512
2580
  // photo 1254x1254 ...]` the human only wrote `사진`, so the placeholder
2513
2581
  // is the wrong thing to surface. The callsite (captureQuoteCandidate)
2514
2582
  // treats an empty residue as "no quote anchor"; mixed inbounds keep the
2515
2583
  // human-written portion. renderQuoteAnchor later collapses whitespace
2516
2584
  // so residual double-spaces from mid-string strips are harmless.
2517
- const CHANNEL_MEDIA_PLACEHOLDER_RE = /\[(?:KakaoTalk|Slack|Discord|Telegram) message with [^\]]*\]/g
2585
+ const CHANNEL_MEDIA_PLACEHOLDER_RE =
2586
+ /\[(?:KakaoTalk|Slack|Discord|Telegram) (?:message with|attachment #\d+:) [^\]]*\]/g
2518
2587
 
2519
2588
  export function stripChannelMediaPlaceholders(text: string): string {
2520
2589
  return text
@@ -2944,6 +3013,36 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
2944
3013
  return KIMI_CHANNEL_TOOL_ID_RE.test(text)
2945
3014
  }
2946
3015
 
3016
+ // Detects the *plain-text* shape of a leaked channel-tool invocation — the
3017
+ // model serialized the tool call as ordinary prose instead of producing a
3018
+ // real tool call. Observed against Kimi-family deployments on KakaoTalk:
3019
+ // the entire assistant message body is literally
3020
+ //
3021
+ // channel_reply({"text":"<the user-facing greeting the bot meant to send>"})
3022
+ //
3023
+ // with no Kimi delimiter tokens (`<|tool_call_begin|>` etc.), so
3024
+ // `isLikelyKimiChannelToolLeak` cannot catch it. Without a guard the
3025
+ // recovery path in `validateChannelTurn` posts this raw function-call
3026
+ // serialization straight to the channel, which is exactly what
3027
+ // users see in the reported screenshots.
3028
+ //
3029
+ // Structural-only detection (NOT a substring search): the trimmed text must
3030
+ // *start* with `channel_reply(` or `channel_send(`, and that opening paren
3031
+ // must enclose at least one `"` (the JSON argument). This deliberately
3032
+ // matches the leak shape while letting prose that merely *mentions* the
3033
+ // tool name (e.g. "I would normally call channel_reply here but...") reach
3034
+ // the user — that false-positive class is already locked in by the
3035
+ // `still recovers legit prose that happens to mention "channel_reply"` test.
3036
+ //
3037
+ // The trailing close paren is NOT required: the model sometimes truncates
3038
+ // mid-serialization, and a half-leaked `channel_reply({"text":"..."` is
3039
+ // just as user-hostile as the full shape.
3040
+ const PLAIN_TEXT_CHANNEL_TOOL_CALL_RE = /^channel_(?:reply|send)\s*\(\s*[^)]*"/
3041
+
3042
+ export function isLikelyPlainTextChannelToolCall(text: string): boolean {
3043
+ return PLAIN_TEXT_CHANNEL_TOOL_CALL_RE.test(text.trim())
3044
+ }
3045
+
2947
3046
  function describe(err: unknown): string {
2948
3047
  return err instanceof Error ? err.message : String(err)
2949
3048
  }
@@ -7,12 +7,56 @@ export type ChannelKey = {
7
7
  thread: string | null
8
8
  }
9
9
 
10
+ // Inbound (non-text) media that the user attached to a channel message.
11
+ // The classifier produces these alongside `InboundMessage.text`; the router
12
+ // stores them and lets channel tools look them up by `id` so the agent can
13
+ // fetch / view a specific attachment without ever seeing the underlying
14
+ // platform-side `ref` (URL, file id, CDN key) in its prompt context.
15
+ //
16
+ // Design contract:
17
+ // - `id` is a 1-based index that is stable WITHIN A SINGLE inbound message
18
+ // and assigned by the adapter classifier. It is NOT globally unique —
19
+ // different inbounds re-use small ids (1, 2, ...). The router's lookup
20
+ // scopes the search to one (adapter,workspace,chat,thread) session and
21
+ // returns the MOST RECENT match across that session's promptQueue +
22
+ // contextBuffer, so within a single turn the agent always resolves
23
+ // `attachment_id: 1` to the attachment on the current inbound — earlier
24
+ // uses of id 1 from buffered context cannot intercept the lookup.
25
+ // - `ref` is the opaque platform handle that the adapter's
26
+ // FetchAttachmentCallback knows how to download (Slack file id, Discord
27
+ // CDN URL, KakaoCDN URL, Telegram file_id). It is INTENTIONALLY not
28
+ // rendered into the user-visible prompt text — keeping it out of the
29
+ // LLM's context prevents the dialect-confusion bug where the agent
30
+ // pastes a malformed ref (e.g. a KakaoCDN bare key) into a tool.
31
+ // - The kind labels (photo/video/...) are coarse on purpose: they exist
32
+ // for the prompt placeholder ("an image arrived") and for tool routing,
33
+ // not for platform-specific behavior.
34
+ export type InboundAttachment = {
35
+ id: number
36
+ kind: 'photo' | 'video' | 'audio' | 'file' | 'sticker' | 'multiphoto' | 'embed'
37
+ ref: string
38
+ // Optional metadata that the adapter classifier may surface for the
39
+ // placeholder rendering. Every field MUST be safe to print into a prompt
40
+ // (no credentials, no long opaque tokens). If a piece of metadata would
41
+ // leak fetchable state, leave it off and rely on `ref` instead.
42
+ mimetype?: string
43
+ filename?: string
44
+ width?: number
45
+ height?: number
46
+ sizeBytes?: number
47
+ }
48
+
10
49
  export type InboundMessage = {
11
50
  adapter: AdapterId
12
51
  workspace: string
13
52
  chat: string
14
53
  thread: string | null
15
54
  text: string
55
+ // Non-text attachments the user sent on this inbound. Empty / omitted
56
+ // when the message is text-only. The router carries these through to
57
+ // the live session's promptQueue/contextBuffer so channel tools can
58
+ // resolve `attachment_id` → ref without the agent ever seeing the ref.
59
+ attachments?: readonly InboundAttachment[]
16
60
  externalMessageId: string
17
61
  authorId: string
18
62
  authorName: string
@@ -84,7 +128,13 @@ export type OutboundMessage = {
84
128
  attachments?: OutboundAttachment[]
85
129
  }
86
130
 
87
- export type SendErrorCode = 'duplicate' | 'turn-cap' | 'no-adapter' | 'callback-rejected' | 'skip-locked'
131
+ export type SendErrorCode =
132
+ | 'duplicate'
133
+ | 'turn-cap'
134
+ | 'outbound-flood'
135
+ | 'no-adapter'
136
+ | 'callback-rejected'
137
+ | 'skip-locked'
88
138
 
89
139
  export type SendResult = { ok: true } | { ok: false; error: string; code?: SendErrorCode }
90
140
 
@@ -124,6 +174,7 @@ export type ChannelHistoryMessage = {
124
174
  authorId: string
125
175
  authorName: string
126
176
  text: string
177
+ attachments?: readonly InboundAttachment[]
127
178
  ts: number
128
179
  isBot: boolean
129
180
  replyToBotMessageId: string | null
@@ -21,6 +21,7 @@ export const BUILTIN_COMMAND_NAMES = [
21
21
  'role',
22
22
  'provider',
23
23
  'model',
24
+ 'mount',
24
25
  'doctor',
25
26
  'usage',
26
27
  'update',
package/src/cli/index.ts CHANGED
@@ -31,6 +31,7 @@ const main = defineCommand({
31
31
  role: () => import('./role').then((m) => m.roleCommand),
32
32
  provider: () => import('./provider').then((m) => m.providerCommand),
33
33
  model: () => import('./model').then((m) => m.modelCommand),
34
+ mount: () => import('./mount').then((m) => m.mountCommand),
34
35
  doctor: () => import('./doctor').then((m) => m.doctorCommand),
35
36
  usage: () => import('./usage').then((m) => m.usageCommand),
36
37
  update: () => import('./update').then((m) => m.updateCommand),
@@ -0,0 +1,157 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { addMount, listMounts, removeMount, type MountListEntry } from '@/config/mounts-mutation'
4
+ import { findAgentDir, isInitialized } from '@/init'
5
+
6
+ import { c, errorLine, successLine } from './ui'
7
+
8
+ const listSub = defineCommand({
9
+ meta: {
10
+ name: 'list',
11
+ description: 'list host directories mounted into the agent container',
12
+ },
13
+ args: {
14
+ json: {
15
+ type: 'boolean',
16
+ description: 'emit mounts as JSON',
17
+ default: false,
18
+ },
19
+ },
20
+ async run({ args }) {
21
+ const cwd = ensureAgentDir()
22
+ const mounts = listMounts(cwd)
23
+ if (args.json) {
24
+ process.stdout.write(`${JSON.stringify({ mounts }, null, 2)}\n`)
25
+ return
26
+ }
27
+ process.stdout.write(`${formatMountList(mounts)}\n`)
28
+ },
29
+ })
30
+
31
+ const addSub = defineCommand({
32
+ meta: {
33
+ name: 'add',
34
+ description: 'add a host directory mount to typeclaw.json',
35
+ },
36
+ args: {
37
+ name: {
38
+ type: 'positional',
39
+ description: 'mount name; appears inside the container at /agent/mounts/<name>',
40
+ required: true,
41
+ },
42
+ path: {
43
+ type: 'positional',
44
+ description: 'host directory path to expose inside the container',
45
+ required: true,
46
+ },
47
+ 'read-only': {
48
+ type: 'boolean',
49
+ description: 'mount read-only inside the container',
50
+ default: false,
51
+ },
52
+ description: {
53
+ type: 'string',
54
+ description: 'optional human-readable note stored in typeclaw.json',
55
+ required: false,
56
+ },
57
+ },
58
+ async run({ args }) {
59
+ const cwd = ensureAgentDir()
60
+ const result = addMount(cwd, args.name, args.path, {
61
+ readOnly: args['read-only'] === true,
62
+ ...(args.description !== undefined ? { description: args.description } : {}),
63
+ })
64
+ if (!result.ok) {
65
+ console.error(errorLine(result.reason))
66
+ process.exit(1)
67
+ }
68
+ process.stdout.write(`${successLine(`Added mount "${result.entry.name}".`)}\n`)
69
+ process.stdout.write(`${formatMountEntry(result.entry)}\n`)
70
+ process.stdout.write(`${c.dim('Apply change:')} ${c.cyan('typeclaw restart')}\n`)
71
+ },
72
+ })
73
+
74
+ const removeSub = defineCommand({
75
+ meta: {
76
+ name: 'remove',
77
+ description: 'remove a host directory mount from typeclaw.json',
78
+ },
79
+ args: {
80
+ name: {
81
+ type: 'positional',
82
+ description: 'mount name to remove',
83
+ required: true,
84
+ },
85
+ },
86
+ async run({ args }) {
87
+ const cwd = ensureAgentDir()
88
+ const result = removeMount(cwd, args.name)
89
+ if (!result.ok) {
90
+ console.error(errorLine(result.reason))
91
+ process.exit(1)
92
+ }
93
+ process.stdout.write(`${successLine(`Removed mount "${result.removed.name}".`)}\n`)
94
+ process.stdout.write(`${c.dim('Apply change:')} ${c.cyan('typeclaw restart')}\n`)
95
+ },
96
+ })
97
+
98
+ export const mountCommand = defineCommand({
99
+ meta: {
100
+ name: 'mount',
101
+ description: 'manage host directories mounted into the agent container',
102
+ },
103
+ subCommands: {
104
+ list: listSub,
105
+ add: addSub,
106
+ remove: removeSub,
107
+ },
108
+ })
109
+
110
+ export function formatMountList(mounts: readonly MountListEntry[]): string {
111
+ if (mounts.length === 0) return c.dim('No mounts configured.')
112
+
113
+ const nameWidth = Math.max(4, ...mounts.map((m) => m.name.length))
114
+ const modeWidth = 'MODE'.length
115
+ const statusWidth = Math.max(6, ...mounts.map((m) => m.status.length))
116
+ const lines: string[] = []
117
+ lines.push(
118
+ c.dim(
119
+ `${'NAME'.padEnd(nameWidth)} ${'MODE'.padEnd(modeWidth)} ${'STATUS'.padEnd(statusWidth)} HOST PATH -> CONTAINER PATH`,
120
+ ),
121
+ )
122
+ for (const mount of mounts) {
123
+ const mode = mount.readOnly ? 'ro' : 'rw'
124
+ const statusText = mount.status.padEnd(statusWidth)
125
+ const status = mount.status === 'ok' ? c.green(statusText) : c.red(statusText)
126
+ lines.push(
127
+ `${mount.name.padEnd(nameWidth)} ${mode.padEnd(modeWidth)} ${status} ${mount.resolvedPath} -> ${mount.targetPath}`,
128
+ )
129
+ if (mount.description !== undefined) {
130
+ lines.push(`${' '.repeat(nameWidth + modeWidth + statusWidth + 6)}${c.dim(mount.description)}`)
131
+ }
132
+ if (mount.statusReason !== undefined) {
133
+ lines.push(`${' '.repeat(nameWidth + modeWidth + statusWidth + 6)}${c.yellow(mount.statusReason)}`)
134
+ }
135
+ }
136
+ return lines.join('\n')
137
+ }
138
+
139
+ function formatMountEntry(mount: MountListEntry): string {
140
+ const mode = mount.readOnly ? 'read-only' : 'read-write'
141
+ const details = [
142
+ `${c.dim('host:')} ${mount.resolvedPath}`,
143
+ `${c.dim('container:')} ${mount.targetPath}`,
144
+ `${c.dim('mode:')} ${mode}`,
145
+ ]
146
+ if (mount.description !== undefined) details.push(`${c.dim('description:')} ${mount.description}`)
147
+ return details.join('\n')
148
+ }
149
+
150
+ function ensureAgentDir(): string {
151
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
152
+ if (!isInitialized(cwd)) {
153
+ console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first, or cd into an agent folder.'))
154
+ process.exit(1)
155
+ }
156
+ return cwd
157
+ }
package/src/cli/update.ts CHANGED
@@ -9,7 +9,7 @@ const MANAGERS = ['auto', 'bun', 'npm', 'pnpm', 'yarn'] as const
9
9
  export const updateCommand = defineCommand({
10
10
  meta: {
11
11
  name: 'update',
12
- description: 'update the globally installed typeclaw CLI',
12
+ description: 'update the installed typeclaw CLI (auto-detects global vs local)',
13
13
  },
14
14
  args: {
15
15
  manager: {
@@ -42,8 +42,9 @@ export const updateCommand = defineCommand({
42
42
  return
43
43
  }
44
44
 
45
- process.stdout.write(`${c.cyan('Updating TypeClaw with:')} ${rendered}\n`)
46
- const exitCode = await runUpdateCommand(plan.command)
45
+ const scopeLabel = plan.scope === 'global' ? 'global' : `local (${plan.cwd ?? '.'})`
46
+ process.stdout.write(`${c.cyan(`Updating TypeClaw [${scopeLabel}] with:`)} ${rendered}\n`)
47
+ const exitCode = await runUpdateCommand(plan.command, plan.cwd)
47
48
  if (exitCode !== 0) {
48
49
  console.error(errorLine(`Update command exited with code ${exitCode}.`))
49
50
  process.exit(exitCode)
@@ -58,7 +59,7 @@ function parseManager(value: string | undefined): UpdateManagerSelection | null
58
59
  return (MANAGERS as readonly string[]).includes(value) ? (value as UpdateManagerSelection) : null
59
60
  }
60
61
 
61
- async function runUpdateCommand(command: string[]): Promise<number> {
62
+ async function runUpdateCommand(command: string[], cwd: string | undefined): Promise<number> {
62
63
  const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
63
64
  if (!bun) {
64
65
  console.error(errorLine('bun runtime not available'))
@@ -67,6 +68,7 @@ async function runUpdateCommand(command: string[]): Promise<number> {
67
68
  try {
68
69
  const proc = bun.spawn({
69
70
  cmd: command,
71
+ cwd,
70
72
  stdin: 'inherit',
71
73
  stdout: 'inherit',
72
74
  stderr: 'inherit',