typeclaw 0.28.2 → 0.30.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 (82) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +43 -5
  3. package/src/agent/live-subagents.ts +5 -0
  4. package/src/agent/loop-guard.ts +112 -26
  5. package/src/agent/plugin-tools.ts +167 -50
  6. package/src/agent/session-origin.ts +3 -3
  7. package/src/agent/subagent-drain.ts +150 -0
  8. package/src/agent/subagents.ts +41 -3
  9. package/src/agent/system-prompt.ts +29 -4
  10. package/src/agent/tools/channel-send.ts +1 -1
  11. package/src/agent/tools/spawn-subagent.ts +34 -1
  12. package/src/agent/tools/subagent-output.ts +7 -3
  13. package/src/agent/tools/wikipedia.ts +1 -1
  14. package/src/bundled-plugins/bun-hygiene/README.md +12 -11
  15. package/src/bundled-plugins/bun-hygiene/policy.ts +8 -3
  16. package/src/bundled-plugins/explorer/explorer.ts +2 -0
  17. package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +94 -0
  18. package/src/bundled-plugins/github-cli-auth/effective-approval.ts +98 -0
  19. package/src/bundled-plugins/github-cli-auth/gh-review-inline-detect.ts +130 -0
  20. package/src/bundled-plugins/github-cli-auth/index.ts +27 -2
  21. package/src/bundled-plugins/github-cli-auth/review-recorder.ts +12 -4
  22. package/src/bundled-plugins/memory/memory-logger.ts +3 -3
  23. package/src/bundled-plugins/operator/operator.ts +2 -0
  24. package/src/bundled-plugins/planner/index.ts +11 -0
  25. package/src/bundled-plugins/planner/planner.ts +283 -0
  26. package/src/bundled-plugins/planner/skills/general.ts +65 -0
  27. package/src/bundled-plugins/planner/skills/project.ts +69 -0
  28. package/src/bundled-plugins/researcher/index.ts +11 -0
  29. package/src/bundled-plugins/researcher/researcher.ts +233 -0
  30. package/src/bundled-plugins/researcher/skills/general.ts +105 -0
  31. package/src/bundled-plugins/researcher/write-report.ts +107 -0
  32. package/src/bundled-plugins/reviewer/reviewer.ts +28 -9
  33. package/src/bundled-plugins/reviewer/skills/data-review.ts +77 -0
  34. package/src/bundled-plugins/reviewer/skills/doc-review.ts +79 -0
  35. package/src/bundled-plugins/reviewer/skills/plan-review.ts +64 -0
  36. package/src/bundled-plugins/reviewer/skills/security-audit.ts +70 -0
  37. package/src/bundled-plugins/reviewer/skills/writing-review.ts +63 -0
  38. package/src/bundled-plugins/scout/scout.ts +2 -0
  39. package/src/bundled-plugins/security/policies/prompt-injection.ts +8 -4
  40. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +3 -2
  41. package/src/channels/adapters/discord-bot.ts +38 -11
  42. package/src/channels/adapters/github/inbound.ts +68 -4
  43. package/src/channels/adapters/kakaotalk-classify.ts +2 -2
  44. package/src/channels/adapters/kakaotalk.ts +2 -2
  45. package/src/channels/adapters/slack-bot-classify.ts +1 -1
  46. package/src/channels/adapters/slack-bot.ts +3 -0
  47. package/src/channels/adapters/telegram-bot.ts +3 -0
  48. package/src/channels/engagement.ts +12 -7
  49. package/src/channels/github-review-claim.ts +15 -3
  50. package/src/channels/router.ts +85 -9
  51. package/src/channels/schema.ts +1 -1
  52. package/src/channels/types.ts +6 -0
  53. package/src/cli/init.ts +13 -2
  54. package/src/cli/ui.ts +64 -0
  55. package/src/config/config.ts +21 -15
  56. package/src/container/start.ts +5 -1
  57. package/src/init/dockerfile.ts +19 -56
  58. package/src/init/hatching.ts +1 -1
  59. package/src/init/index.ts +5 -1
  60. package/src/migrations/index.ts +35 -0
  61. package/src/migrations/secrets-v1-to-v2.ts +344 -0
  62. package/src/run/bundled-plugins.ts +4 -0
  63. package/src/run/index.ts +13 -0
  64. package/src/sandbox/availability.ts +12 -0
  65. package/src/sandbox/build.ts +12 -0
  66. package/src/sandbox/index.ts +1 -1
  67. package/src/sandbox/policy.ts +8 -0
  68. package/src/server/index.ts +24 -5
  69. package/src/shared/host-locale.ts +27 -0
  70. package/src/shared/protocol.ts +1 -1
  71. package/src/shared/wordmark.ts +19 -0
  72. package/src/skills/typeclaw-config/SKILL.md +32 -32
  73. package/src/skills/typeclaw-kaomoji/SKILL.md +3 -3
  74. package/src/skills/typeclaw-tunnels/SKILL.md +3 -1
  75. package/src/tui/banner.ts +19 -0
  76. package/src/tui/format.ts +34 -0
  77. package/src/tui/index.ts +121 -22
  78. package/src/tui/theme.ts +26 -1
  79. package/src/tunnels/providers/cloudflare-named.ts +15 -4
  80. package/src/tunnels/providers/cloudflare-quick.ts +15 -4
  81. package/src/tunnels/providers/cloudflared-binary.ts +11 -0
  82. package/typeclaw.schema.json +15 -7
@@ -86,7 +86,7 @@ export function classifyInbound(
86
86
  // without any new config surface.
87
87
  const hasGroupMention = GROUP_MENTION_PATTERN.test(rawText)
88
88
  const isBotMention = hasGroupMention || rawText.includes(`<@${context.botUserId}>`)
89
- // Top-level alias addressing (e.g. "윙키야") is engagement-equivalent
89
+ // Top-level alias addressing (e.g. "Momo!" / "@Momo" by name) is engagement-equivalent
90
90
  // to a `<@bot>` mention (see engagement.ts: alias is unconditional and
91
91
  // ranks alongside explicit triggers). Anchor `thread` on the inbound
92
92
  // ts in that case too, so the bot's reply threads under the user's
@@ -1213,6 +1213,7 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1213
1213
  options.router.registerReaction('slack-bot', reactionCallback)
1214
1214
  options.router.registerRemoveReaction('slack-bot', removeReactionCallback)
1215
1215
  options.router.registerTyping('slack-bot', typingCallback)
1216
+ options.router.setTypingCapability('slack-bot', true)
1216
1217
  options.router.registerChannelNameResolver('slack-bot', channelResolver)
1217
1218
  options.router.registerSelfIdentity('slack-bot', selfIdentityResolver)
1218
1219
  options.router.registerHistory('slack-bot', historyCallback)
@@ -1230,6 +1231,7 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1230
1231
  options.router.unregisterReaction('slack-bot', reactionCallback)
1231
1232
  options.router.unregisterRemoveReaction('slack-bot', removeReactionCallback)
1232
1233
  options.router.unregisterTyping('slack-bot', typingCallback)
1234
+ options.router.setTypingCapability('slack-bot', false)
1233
1235
  options.router.unregisterChannelNameResolver('slack-bot', channelResolver)
1234
1236
  options.router.unregisterSelfIdentity('slack-bot', selfIdentityResolver)
1235
1237
  options.router.unregisterHistory('slack-bot', historyCallback)
@@ -1251,6 +1253,7 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1251
1253
  options.router.unregisterReaction('slack-bot', reactionCallback)
1252
1254
  options.router.unregisterRemoveReaction('slack-bot', removeReactionCallback)
1253
1255
  options.router.unregisterTyping('slack-bot', typingCallback)
1256
+ options.router.setTypingCapability('slack-bot', false)
1254
1257
  options.router.unregisterChannelNameResolver('slack-bot', channelResolver)
1255
1258
  options.router.unregisterSelfIdentity('slack-bot', selfIdentityResolver)
1256
1259
  options.router.unregisterHistory('slack-bot', historyCallback)
@@ -529,6 +529,7 @@ export function createTelegramBotAdapter(options: TelegramBotAdapterOptions): Te
529
529
 
530
530
  options.router.registerOutbound('telegram-bot', outboundCallback)
531
531
  options.router.registerTyping('telegram-bot', typingCallback)
532
+ options.router.setTypingCapability('telegram-bot', true)
532
533
  options.router.registerChannelNameResolver('telegram-bot', channelResolver)
533
534
  options.router.registerSelfIdentity('telegram-bot', selfIdentityResolver)
534
535
  options.router.registerFetchAttachment('telegram-bot', fetchAttachmentCallback)
@@ -537,6 +538,7 @@ export function createTelegramBotAdapter(options: TelegramBotAdapterOptions): Te
537
538
  const rollbackStart = (reason: string, cause: Error): never => {
538
539
  options.router.unregisterOutbound('telegram-bot', outboundCallback)
539
540
  options.router.unregisterTyping('telegram-bot', typingCallback)
541
+ options.router.setTypingCapability('telegram-bot', false)
540
542
  options.router.unregisterChannelNameResolver('telegram-bot', channelResolver)
541
543
  options.router.unregisterSelfIdentity('telegram-bot', selfIdentityResolver)
542
544
  options.router.unregisterFetchAttachment('telegram-bot', fetchAttachmentCallback)
@@ -565,6 +567,7 @@ export function createTelegramBotAdapter(options: TelegramBotAdapterOptions): Te
565
567
  started = false
566
568
  options.router.unregisterOutbound('telegram-bot', outboundCallback)
567
569
  options.router.unregisterTyping('telegram-bot', typingCallback)
570
+ options.router.setTypingCapability('telegram-bot', false)
568
571
  options.router.unregisterChannelNameResolver('telegram-bot', channelResolver)
569
572
  options.router.unregisterSelfIdentity('telegram-bot', selfIdentityResolver)
570
573
  options.router.unregisterFetchAttachment('telegram-bot', fetchAttachmentCallback)
@@ -89,6 +89,8 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
89
89
  if (config.trigger.includes('mention') && message.isBotMention) return 'engage'
90
90
  if (config.trigger.includes('reply') && message.replyToBotMessageId !== null) return 'engage'
91
91
 
92
+ const explicitOnly = message.suppressSticky === true
93
+
92
94
  // Multi-human pre-sticky target check. In a busy group the conversational
93
95
  // target shifts every message: the author we're mid-exchange with (and hold
94
96
  // a sticky credit for) may, on THIS turn, structurally address a third party
@@ -112,12 +114,13 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
112
114
  // The `!matchesAnyAlias` guard preserves the ladder invariant "explicit
113
115
  // address to us beats structural targeting of others": a message that names
114
116
  // us by alias engages on the alias rule below even when it ALSO tags a third
115
- // party (the "봉봉아 펭펭아 " multi-bot case), so we must not pre-empt
117
+ // party (the "Toto, Lala, both take a look" multi-bot case), so we must not pre-empt
116
118
  // it here. We only step aside for a credited author whose message is aimed
117
119
  // PURELY elsewhere.
118
120
  if (
119
121
  effectiveHumans > 1 &&
120
122
  config.stickiness !== 'off' &&
123
+ !explicitOnly &&
121
124
  !matchesAnyAlias(message.text, selfAliases) &&
122
125
  targetsSomeoneElse(message, participants, botInThread)
123
126
  ) {
@@ -134,7 +137,9 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
134
137
  // groups wholesale (the prior approach) dropped genuine follow-ups outright;
135
138
  // the pre-check above is narrower — it only steps aside when the message is
136
139
  // STRUCTURALLY addressed elsewhere, leaving plain follow-ups engaged.
137
- if (config.stickiness !== 'off' && ledger.consume(key, message.authorId, now)) {
140
+ // GitHub review-thread traffic must not spend content-blind sticky credit
141
+ // unless the bot was explicitly addressed.
142
+ if (!explicitOnly && config.stickiness !== 'off' && ledger.consume(key, message.authorId, now)) {
138
143
  return 'engage'
139
144
  }
140
145
 
@@ -143,12 +148,12 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
143
148
  // same priority as an explicit mention — operators add aliases
144
149
  // precisely because they expect the bot to respond when called by
145
150
  // name. Suppression on `mentionsOthers` would defeat the point: the
146
- // user can address two bots by name in one message ("봉봉아 펭펭아
147
- // ") and both should engage. Each bot only knows its own
151
+ // user can address two bots by name in one message ("Toto, Lala, both
152
+ // take a look") and both should engage. Each bot only knows its own
148
153
  // aliases, so cross-bot suppression isn't possible at this layer
149
154
  // anyway — the router-side peer-name suppression in the solo-human
150
155
  // fallback handles that case (follow-up).
151
- if (matchesAnyAlias(message.text, selfAliases)) return 'engage'
156
+ if (!explicitOnly && matchesAnyAlias(message.text, selfAliases)) return 'engage'
152
157
 
153
158
  // Solo-human fallback: the strict mention/reply/dm gate keeps the bot
154
159
  // quiet in multi-human conversations, but in a 1-human channel that
@@ -176,8 +181,8 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
176
181
  // who don't want to type `@bot` in their own DM-like channel; peer bots
177
182
  // have no such ergonomic excuse. Letting peer bots ride the fallback
178
183
  // produced bot-to-bot conversations in 1-human-N-bot channels (observed:
179
- // Winky and 돌쇠 introducing themselves to each other after a single
180
- // "얘들아" from the human, then continuing to address each other for
184
+ // Momo and Kiki introducing themselves to each other after a single
185
+ // "hey folks" from the human, then continuing to address each other for
181
186
  // ~6 turns). The router's loop guard only trips after 5 consecutive
182
187
  // peer engagements, which is too late to prevent the embarrassment.
183
188
  //
@@ -53,6 +53,12 @@ const WARN_POSITIVE_CLOSEOUT: readonly RegExp[] = [
53
53
  /\bshould be (fine|good)\b/,
54
54
  /\blooks resolved\b/,
55
55
  /\bseems resolved\b/,
56
+ // The canonical PR #672 close-out: "that addresses the concern", "addressed
57
+ // your feedback". On a PR the bot still blocks, this READS as a verdict and
58
+ // strands the block, so it escalates through the re-review guard. Demoted to
59
+ // ignore by the negation/future markers below ("haven't addressed", "to
60
+ // address").
61
+ /\baddress(es|ed)\b[^.!?]*\b(concern|feedback|review|comment|issue|point)/,
56
62
  ]
57
63
 
58
64
  // Negative warn phrases re-assert a block ("not done yet") instead of closing it
@@ -65,11 +71,17 @@ const WARN: readonly RegExp[] = [...WARN_POSITIVE_CLOSEOUT, ...WARN_NEGATIVE]
65
71
  // ignore. Blocking "I haven't approved" / "I'll approve" / "approved it earlier"
66
72
  // (answering a question) is the worst false-positive class, so it is checked first.
67
73
  const DEMOTE_TO_IGNORE: readonly RegExp[] = [
68
- /\b(haven'?t|have not|did ?n'?t|did not|not yet|never)\b[^.!?]*\b(approv|request|resolv|block)/,
69
- /\b(can'?t|cannot|won'?t|will not|wouldn'?t)\b[^.!?]*\b(approv|request|resolv|block)/,
70
- /\bnot (approved|resolved|blocked|requesting)\b/,
74
+ /\b(haven'?t|have not|did ?n'?t|did not|not yet|never)\b[^.!?]*\b(approv|request|resolv|block|address)/,
75
+ /\b(can'?t|cannot|won'?t|will not|wouldn'?t)\b[^.!?]*\b(approv|request|resolv|block|address)/,
76
+ /\bnot (approved|resolved|blocked|requesting|addressed)\b/,
71
77
  /\b(not|no longer|hardly|barely)\b[^.!?]*\b(lgtm|looks good|looks fine|seems fine|should be (fine|good)|looks resolved|seems resolved)\b/,
72
78
  /\b(i'?ll|i will|going to|gonna|about to|planning to)\b[^.!?]*\b(approv|review|request|resolv)/,
79
+ // "address" demotion is restricted to explicit future/obligation forms only.
80
+ // A standalone `to` marker (e.g. "...to address my feedback") would match
81
+ // hard-claim prose like "Approved — thanks for updating the docs to address
82
+ // my feedback" and demote it to ignore BEFORE the BLOCK_APPROVE check, hiding
83
+ // a real verdict (the recovery path would then post it unguarded — PR #675).
84
+ /\b(i'?ll|i will|going to|gonna|about to|planning to|need(s)? to|have to|want(s)? to|trying to)\b[^.!?]*\baddress/,
73
85
  /\b(approved|resolved|requested changes)\b[^.!?]*\b(earlier|already|yesterday|before|last (review|time)|previously)\b/,
74
86
  /\b(pre|self|co|re|un|non|ai|admin|user|machine|auto) approved\b/,
75
87
  ]
@@ -32,6 +32,8 @@ import {
32
32
  StickyLedger,
33
33
  type EngagementDecision,
34
34
  } from './engagement'
35
+ import { checkFalseReceipt } from './github-false-receipt'
36
+ import { evaluateRereviewGuard } from './github-rereview-guard'
35
37
  import { resetReviewTurn } from './github-review-turn-ledger'
36
38
  import {
37
39
  MEMBERSHIP_COLD_FETCH_TIMEOUT_MS,
@@ -659,6 +661,11 @@ export type ChannelRouter = {
659
661
  removeReaction: (req: RemoveReactionRequest) => Promise<ReactionResult>
660
662
  registerTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
661
663
  unregisterTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
664
+ // Deliberately separate from registerTyping: github registers a no-op typing
665
+ // callback (no typing API) yet must stay typing-less, so "has a callback" is
666
+ // the wrong signal. autoReactOnEngage reads this to post :eyes: only as a
667
+ // fallback when no visible typing exists. Unset defaults to false.
668
+ setTypingCapability: (adapter: ChannelKey['adapter'], supported: boolean) => void
662
669
  registerChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
663
670
  unregisterChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
664
671
  // Self-identity is a per-adapter singleton (one bot account per adapter),
@@ -953,6 +960,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
953
960
  const reactionCallbacks = new Map<ChannelKey['adapter'], Set<ReactionCallback>>()
954
961
  const removeReactionCallbacks = new Map<ChannelKey['adapter'], Set<RemoveReactionCallback>>()
955
962
  const typingCallbacks = new Map<ChannelKey['adapter'], Set<TypingCallback>>()
963
+ const typingCapableAdapters = new Set<ChannelKey['adapter']>()
956
964
  const channelNameResolvers = new Map<ChannelKey['adapter'], Set<ChannelNameResolver>>()
957
965
  const membershipResolvers = new Map<ChannelKey['adapter'], Set<MembershipResolver>>()
958
966
  const selfIdentityResolvers = new Map<ChannelKey['adapter'], ChannelSelfIdentityResolver>()
@@ -1513,7 +1521,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1513
1521
  logger.error(`[channels] ${keyId}: ensureLive failed: ${describe(err)}`)
1514
1522
  throw err
1515
1523
  } finally {
1516
- creating.delete(keyId)
1524
+ // Owner-checked delete: only clear the in-flight marker if it still points
1525
+ // at THIS promise. A watchdog timeout can orphan a slow creation whose
1526
+ // `finally` runs while a later inbound has already installed its own
1527
+ // `creating` entry for the same key; an unconditional delete would drop
1528
+ // that newer entry and let a third inbound cold-start a duplicate session
1529
+ // (observed: 3 concurrent sessions approving the same PR).
1530
+ if (creating.get(keyId) === promise) creating.delete(keyId)
1517
1531
  }
1518
1532
  }
1519
1533
 
@@ -2447,13 +2461,18 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2447
2461
  }
2448
2462
 
2449
2463
  // Best-effort acknowledgment: drop an :eyes: on the triggering inbound the
2450
- // moment we decide to engage, replacing the old "On it" ack comment on
2451
- // GitHub. Fire-and-forget so a reaction failure (missing permission, the
2452
- // adapter not supporting reactions, a transient API error) can NEVER block
2453
- // engagement, enqueueing, or the agent's actual reply. No reactionRef =
2454
- // nothing reactable (synthetic inbounds, reaction-less adapters) = silent skip.
2464
+ // moment we decide to engage but ONLY when the channel has no visible
2465
+ // "typing…" indicator. Where typing renders (slack/discord/telegram) the
2466
+ // heartbeat already signals "the bot is working", so the reaction would be
2467
+ // redundant noise; the :eyes: is the fallback ack for typing-less channels
2468
+ // (github, kakaotalk), replacing the old "On it" comment on GitHub.
2469
+ // Fire-and-forget so a reaction failure (missing permission, the adapter not
2470
+ // supporting reactions, a transient API error) can NEVER block engagement,
2471
+ // enqueueing, or the agent's actual reply. No reactionRef = nothing reactable
2472
+ // (synthetic inbounds, reaction-less adapters) = silent skip.
2455
2473
  const autoReactOnEngage = (event: InboundMessage): Promise<ReactionRef | null> | null => {
2456
2474
  if (event.reactionRef === undefined) return null
2475
+ if (typingCapableAdapters.has(event.adapter)) return null
2457
2476
  const addResult = react({
2458
2477
  adapter: event.adapter,
2459
2478
  workspace: event.workspace,
@@ -2522,6 +2541,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2522
2541
  typingCallbacks.get(adapter)?.delete(cb)
2523
2542
  }
2524
2543
 
2544
+ const setTypingCapability = (adapter: ChannelKey['adapter'], supported: boolean): void => {
2545
+ if (supported) typingCapableAdapters.add(adapter)
2546
+ else typingCapableAdapters.delete(adapter)
2547
+ }
2548
+
2525
2549
  const registerChannelNameResolver = (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver): void => {
2526
2550
  let set = channelNameResolvers.get(adapter)
2527
2551
  if (!set) {
@@ -3103,6 +3127,25 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3103
3127
  // the model's pre-tool commentary is the only user-facing text we have.
3104
3128
  // Recovering it means the user gets *something* — strictly better than
3105
3129
  // the historical silent drop.
3130
+ // Egress-level GitHub review guards. The false-receipt and re-review
3131
+ // stranding guards live inside the channel_reply / channel_send tool
3132
+ // handlers, but recovery surfaces trailing assistant prose through a
3133
+ // `source:'system'` send that never touches those handlers. A model that
3134
+ // ends its turn with a close-out ack ("that addresses the concern") instead
3135
+ // of calling a channel tool would otherwise post a verdict-shaped comment
3136
+ // while still holding its own CHANGES_REQUESTED — stranding the PR (PR #672).
3137
+ // Re-run the guards here and SUPPRESS on block: recovery cannot land the
3138
+ // missing formal review on the model's behalf, and posting the unguarded ack
3139
+ // is worse than dropping it — the next inbound re-prompts the model, which
3140
+ // can then land the verdict properly.
3141
+ const recoveryBlock = await evaluateRecoveryReviewGuards(live, assistantText)
3142
+ if (recoveryBlock !== null) {
3143
+ logger.warn(
3144
+ `[channels] ${live.keyId}: suppressed recovery (github review guard) reason=${JSON.stringify(recoveryBlock)} text_len=${assistantText.length}`,
3145
+ )
3146
+ return
3147
+ }
3148
+
3106
3149
  logger.warn(
3107
3150
  `[channels] ${live.keyId}: recovering assistant_text_without_channel_tool source=${source} text_len=${assistantText.length}`,
3108
3151
  )
@@ -3121,6 +3164,38 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3121
3164
  }
3122
3165
  }
3123
3166
 
3167
+ // Returns a block reason when the recovered text would be denied by a github
3168
+ // review guard, or null when it is safe to surface. Non-github channels and
3169
+ // non-PR chats short-circuit inside each guard (adapter / `pr:\d+` checks), so
3170
+ // this is a no-op for everything except GitHub PR sessions.
3171
+ const evaluateRecoveryReviewGuards = async (live: LiveSession, text: string): Promise<string | null> => {
3172
+ const falseReceipt = checkFalseReceipt({
3173
+ sessionId: live.sessionId,
3174
+ adapter: live.key.adapter,
3175
+ workspace: live.key.workspace,
3176
+ chat: live.key.chat,
3177
+ thread: live.key.thread,
3178
+ text,
3179
+ isContinue: false,
3180
+ resolveReviewThread: false,
3181
+ })
3182
+ if (falseReceipt.kind === 'block') return falseReceipt.reason
3183
+
3184
+ const rereview = await evaluateRereviewGuard({
3185
+ adapter: live.key.adapter,
3186
+ workspace: live.key.workspace,
3187
+ chat: live.key.chat,
3188
+ thread: live.key.thread,
3189
+ text,
3190
+ wantsResolve: false,
3191
+ isContinue: false,
3192
+ getReviewState: (req) => getReviewState(req),
3193
+ })
3194
+ if (rereview.block) return rereview.reason
3195
+
3196
+ return null
3197
+ }
3198
+
3124
3199
  const getConsecutiveSendCount = (target: {
3125
3200
  adapter: ChannelKey['adapter']
3126
3201
  workspace: string
@@ -3550,6 +3625,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3550
3625
  removeReaction,
3551
3626
  registerTyping,
3552
3627
  unregisterTyping,
3628
+ setTypingCapability,
3553
3629
  registerChannelNameResolver,
3554
3630
  unregisterChannelNameResolver,
3555
3631
  registerSelfIdentity,
@@ -3706,7 +3782,7 @@ function composeTurnPrompt(
3706
3782
  // sections used for actual conversation content (`## Recent context`,
3707
3783
  // `## Current message`). Without the fencing, models — especially
3708
3784
  // persona-rich ones like Kimi — read the heading as a human-authored
3709
- // instruction and reply to it ("알겠습니다, 대화 여기까지 할게요"). The
3785
+ // instruction and reply to it (e.g. "Understood, I'll stop here"). The
3710
3786
  // bracketed marker plus the explicit "Do not acknowledge or reply to
3711
3787
  // this notice" line is the trust boundary that prevents this. New
3712
3788
  // runtime notices (rate-limit, schema-mismatch, abort signals, etc.)
@@ -3935,8 +4011,8 @@ export type QuoteAnchorTarget = {
3935
4011
  // Slack/Discord/Telegram attachments). The quote anchor is a UX
3936
4012
  // affordance pointing the human at *their words* — quoting a sticker as
3937
4013
  // `> Alice: [KakaoTalk attachment #1: sticker name=...]`
3938
- // is noise, and for mixed inbounds like `사진 [KakaoTalk message with
3939
- // photo 1254x1254 ...]` the human only wrote `사진`, so the placeholder
4014
+ // is noise, and for mixed inbounds like `<caption> [KakaoTalk message with
4015
+ // photo 1254x1254 ...]` the human only wrote the caption, so the placeholder
3940
4016
  // is the wrong thing to surface. The callsite (captureQuoteCandidate)
3941
4017
  // treats an empty residue as "no quote anchor"; mixed inbounds keep the
3942
4018
  // human-written portion. renderQuoteAnchor later collapses whitespace
@@ -243,7 +243,7 @@ const githubChannelSchema = adapterSchema.extend({
243
243
  // KakaoTalk uses the same shape as every other adapter. There used to be an
244
244
  // `autoMarkRead` opt-in here; the adapter now fires a LOCO NOTIREAD ack on
245
245
  // every inbound MSG event unconditionally (see kakaotalk.ts) so the sender's
246
- // unread "1" (노란숫자) clears as soon as the agent observes the message.
246
+ // unread "1" (the yellow unread badge) clears as soon as the agent observes the message.
247
247
  // Existing configs with `autoMarkRead: <bool>` continue to parse — Zod's
248
248
  // default `.object()` strips unknown keys silently — but the field has no
249
249
  // effect. Risk note: auto-acking every received message is a distinct
@@ -70,6 +70,12 @@ export type InboundMessage = {
70
70
  // bounded loop guard so two or more bots cannot ping-pong forever.
71
71
  authorIsBot: boolean
72
72
  isBotMention: boolean
73
+ // When true, engagement treats this inbound as explicit-only: it skips
74
+ // content-blind sticky credit AND plain-text alias matching, leaving only
75
+ // structural DM / @mention / reply triggers. Used for GitHub PR review-thread
76
+ // traffic so the bot observes review comments unless explicitly addressed.
77
+ // Adapters that omit this keep the normal sticky + alias behavior.
78
+ suppressSticky?: boolean
73
79
  replyToBotMessageId: string | null
74
80
  // True when the message contains at least one user mention AND none of
75
81
  // those mentions resolve to the bot. Used by the engagement layer to
package/src/cli/init.ts CHANGED
@@ -36,7 +36,16 @@ import { API_KEY_DASHBOARD_URL, validateApiKey, type KeyValidationResult } from
36
36
 
37
37
  import { buildOAuthCallbacks } from './oauth-callbacks'
38
38
  import { CANCEL_SYMBOL, promptPrivateKeyPem } from './prompt-pem'
39
- import { c, done, errorLine, printDiscordInviteHint, printSlackAppManifestSetup } from './ui'
39
+ import {
40
+ c,
41
+ cornflower,
42
+ done,
43
+ errorLine,
44
+ printDiscordInviteHint,
45
+ printHatchedFlourish,
46
+ printInitWelcome,
47
+ printSlackAppManifestSetup,
48
+ } from './ui'
40
49
 
41
50
  // ESC and Ctrl+C both produce clack's cancel symbol (the keypress layer
42
51
  // aliases both to the same "cancel" action — there's no way to tell them
@@ -119,6 +128,7 @@ export const init = defineCommand({
119
128
  }
120
129
  }
121
130
 
131
+ printInitWelcome()
122
132
  intro('Initializing TypeClaw...')
123
133
  log.info('Press ESC at any prompt to go back. Press ESC twice in a row to abort.')
124
134
 
@@ -272,7 +282,8 @@ export const init = defineCommand({
272
282
  'Claim ownership before chatting',
273
283
  )
274
284
  }
275
- done({ title: c.green('Hatched. Your agent is ready.'), hints })
285
+ printHatchedFlourish()
286
+ done({ title: `${cornflower('✓')} ${c.bold('Hatched.')} Your agent is ready.`, hints })
276
287
  }
277
288
  },
278
289
  })
package/src/cli/ui.ts CHANGED
@@ -4,6 +4,7 @@ import { cancel, intro, isCancel, log, note, outro, spinner as clackSpinner } fr
4
4
 
5
5
  import { buildDiscordInviteUrl, deriveAppIdFromBotToken } from '@/channels/adapters/discord-bot-invite'
6
6
  import { type AutoUpgradeOutcome, describeAutoUpgrade } from '@/init/auto-upgrade'
7
+ import { COMPACT_WORDMARK, WORDMARK_LINES, WORDMARK_WIDTH } from '@/shared/wordmark'
7
8
 
8
9
  export { cancel, intro, isCancel, log, note, outro }
9
10
 
@@ -61,6 +62,69 @@ export function link(text: string, url: string): string {
61
62
  return `\u001b]8;;${url}\u0007${text}\u001b]8;;\u0007`
62
63
  }
63
64
 
65
+ // Brand truecolor sampled from the typeey mascot, matching src/tui/theme.ts.
66
+ // `c`/styleText only carry the 16 named colors, so the cornflower/amber accents
67
+ // are emitted as raw 24-bit SGR — gated on colorsEnabled() so NO_COLOR and
68
+ // piped output never see an escape.
69
+ const CORNFLOWER_FG = '\x1b[38;2;91;127;212m'
70
+ const AMBER_FG = '\x1b[38;2;231;143;55m'
71
+ const RESET = '\x1b[0m'
72
+ const BOLD = '\x1b[1m'
73
+ const DIM = '\x1b[2m'
74
+
75
+ const INIT_TAGLINE = 'the TypeScript-native agent runtime'
76
+
77
+ export function cornflower(s: string): string {
78
+ if (!colorsEnabled()) return s
79
+ return `${CORNFLOWER_FG}${s}${RESET}`
80
+ }
81
+
82
+ export type InitWelcomeOptions = {
83
+ isTty: boolean
84
+ columns: number
85
+ colorsEnabled: boolean
86
+ }
87
+
88
+ export function renderInitWelcome(opts: InitWelcomeOptions): string {
89
+ // Non-TTY (piped/CI): emit nothing so captured/redirected output stays clean.
90
+ if (!opts.isTty) return ''
91
+ const tagline = opts.colorsEnabled ? `${DIM}${INIT_TAGLINE}${RESET}` : INIT_TAGLINE
92
+ if (opts.columns < WORDMARK_WIDTH + 2) {
93
+ const mark = opts.colorsEnabled ? `${BOLD}${CORNFLOWER_FG}${COMPACT_WORDMARK}${RESET}` : COMPACT_WORDMARK
94
+ return `${mark}\n${tagline}`
95
+ }
96
+ const art = opts.colorsEnabled
97
+ ? WORDMARK_LINES.map((line) => `${CORNFLOWER_FG}${line}${RESET}`).join('\n')
98
+ : WORDMARK_LINES.join('\n')
99
+ return `${art}\n${tagline}`
100
+ }
101
+
102
+ export function printInitWelcome(output: NodeJS.WritableStream = process.stdout): void {
103
+ const banner = renderInitWelcome({
104
+ isTty: Boolean(process.stdout.isTTY),
105
+ columns: process.stdout.columns ?? 80,
106
+ colorsEnabled: colorsEnabled(),
107
+ })
108
+ if (banner === '') return
109
+ // Pad above (clear the shell prompt) and below (separate from clack's intro).
110
+ output.write(`\n${banner}\n\n`)
111
+ }
112
+
113
+ export function renderHatchedFlourish(opts: { isTty: boolean; colorsEnabled: boolean }): string {
114
+ if (!opts.isTty) return ''
115
+ if (!opts.colorsEnabled) return '✦ hatched!'
116
+ return `${AMBER_FG}✦${RESET} ${CORNFLOWER_FG}hatched!${RESET}`
117
+ }
118
+
119
+ export function printHatchedFlourish(output: NodeJS.WritableStream = process.stdout): void {
120
+ const line = renderHatchedFlourish({
121
+ isTty: Boolean(process.stdout.isTTY),
122
+ colorsEnabled: colorsEnabled(),
123
+ })
124
+ if (line === '') return
125
+ output.write(`${line}\n`)
126
+ }
127
+
64
128
  export type Spinner = {
65
129
  start: (msg?: string) => void
66
130
  stop: (msg?: string) => void
@@ -171,23 +171,29 @@ const dockerfileObjectSchema = z.object({
171
171
  gh: dockerfileFeatureSchema.default(true),
172
172
  python: z.boolean().default(true),
173
173
  tmux: dockerfileFeatureSchema.default(true),
174
- // `fonts-noto-cjk` is a ~56MB metapackage that makes Chromium render
174
+ // `fonts-noto-cjk` is an ~89MB metapackage that makes Chromium render
175
175
  // Korean/Japanese/Chinese glyphs correctly in screenshots, `page.pdf()`,
176
- // and any other raster output the agent-browser plugin produces. Default
177
- // `true` because the alternative silent tofu boxes (□□□) in CJK
178
- // screenshots is a confusing failure mode that an agent cannot self-
179
- // diagnose from a screenshot it took itself. Opt-out with `cjkFonts:
180
- // false` to save the ~56MB on agents that never touch CJK content.
181
- // Boolean-only (no version pin) because the package is a metapackage
182
- // tracking the upstream Noto release and version pinning offers no
183
- // practical value.
184
- cjkFonts: z.boolean().default(true),
176
+ // and any other raster output the agent-browser plugin produces. Without
177
+ // it CJK text renders as silent tofu boxes (□□□) a confusing failure an
178
+ // agent cannot self-diagnose from a screenshot it took itself.
179
+ //
180
+ // Default `'auto'`: resolved at `typeclaw start` from the HOST locale
181
+ // (`LANG`/`LC_ALL`/`Intl`), same host-signal pattern as timezone
182
+ // detection. CJK host (ja/ko/zh) install; otherwise skip the ~89MB. The
183
+ // resolved boolean is baked into the emitted Dockerfile so the image stays
184
+ // reproducible per-build. Force with an explicit `true`/`false` to bypass
185
+ // detection. String-or-boolean (no version pin) because the package is a
186
+ // metapackage tracking the upstream Noto release.
187
+ cjkFonts: z.union([z.boolean(), z.literal('auto')]).default('auto'),
185
188
  // Opt into the cloudflared layer for `cloudflare-quick` tunnels. Default
186
- // `true` so `tunnel add` / `channel add github` with the default Cloudflare
187
- // Quick provider works on the next `start` without a separate Dockerfile
188
- // edit. Opt-out with `cloudflared: false` to skip the ~35MB binary on
189
- // agents that don't use tunnels.
190
- cloudflared: z.boolean().default(true),
189
+ // `false` to skip the ~38MB binary on agents that don't use tunnels (the
190
+ // common case). `typeclaw tunnel add` / `channel add github` with a
191
+ // Cloudflare provider flip this to `true` automatically and prompt for a
192
+ // restart, so the happy path still works; only hand-edited configs need to
193
+ // set it explicitly. When the binary is absent at tunnel start, the
194
+ // provider fails with a clear "enable docker.file.cloudflared and restart"
195
+ // message rather than a cryptic spawn error.
196
+ cloudflared: z.boolean().default(false),
191
197
  // Install xvfb so the entrypoint shim can spawn an Xvfb virtual X
192
198
  // server and export DISPLAY, giving headed Chrome (agent-browser
193
199
  // --headed, Playwright headful) a real X11 display to connect to.
@@ -22,6 +22,7 @@ import { ensureDepsInstalled, type EnsureDepsResult } from '@/init/ensure-deps'
22
22
  import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
23
23
  import { refreshPackageJson } from '@/init/packagejson'
24
24
  import { runBunUpdate, type UpdateRunner } from '@/init/run-bun-install'
25
+ import { hostLocaleIsCjk } from '@/shared/host-locale'
25
26
 
26
27
  import { CONTAINER_PORT, TUI_TOKEN_LABEL, findFreePort, isPortAllocatedError, resolveTuiToken } from './port'
27
28
  import {
@@ -591,7 +592,10 @@ async function resolvePublishHost(exec: DockerExec): Promise<string> {
591
592
  // image.
592
593
  export async function refreshDockerfile(cwd: string): Promise<{ changed: boolean }> {
593
594
  const cfg = await loadTypeclawConfig(cwd)
594
- const next = buildDockerfile(cfg.docker.file, { baseImageVersion: resolveBaseImageVersion(cwd) })
595
+ const next = buildDockerfile(cfg.docker.file, {
596
+ baseImageVersion: resolveBaseImageVersion(cwd),
597
+ cjkFontsAuto: hostLocaleIsCjk(),
598
+ })
595
599
  const path = join(cwd, DOCKERFILE)
596
600
  const prev = await readFile(path, 'utf8').catch(() => null)
597
601
  if (prev === next) return { changed: false }