typeclaw 0.28.1 → 0.29.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 (78) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +37 -5
  3. package/src/agent/loop-guard.ts +112 -26
  4. package/src/agent/plugin-tools.ts +102 -41
  5. package/src/agent/session-origin.ts +3 -3
  6. package/src/agent/subagents.ts +7 -0
  7. package/src/agent/system-prompt.ts +29 -4
  8. package/src/agent/tools/channel-reply.ts +1 -0
  9. package/src/agent/tools/channel-send.ts +2 -1
  10. package/src/agent/tools/spawn-subagent.ts +21 -0
  11. package/src/agent/tools/subagent-output.ts +7 -3
  12. package/src/agent/tools/wikipedia.ts +1 -1
  13. package/src/bundled-plugins/explorer/explorer.ts +2 -0
  14. package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +74 -0
  15. package/src/bundled-plugins/github-cli-auth/effective-approval.ts +98 -0
  16. package/src/bundled-plugins/github-cli-auth/gh-review-inline-detect.ts +130 -0
  17. package/src/bundled-plugins/github-cli-auth/index.ts +27 -2
  18. package/src/bundled-plugins/github-cli-auth/review-recorder.ts +12 -4
  19. package/src/bundled-plugins/memory/memory-logger.ts +3 -3
  20. package/src/bundled-plugins/operator/operator.ts +2 -0
  21. package/src/bundled-plugins/planner/index.ts +11 -0
  22. package/src/bundled-plugins/planner/planner.ts +282 -0
  23. package/src/bundled-plugins/planner/skills/general.ts +65 -0
  24. package/src/bundled-plugins/planner/skills/project.ts +69 -0
  25. package/src/bundled-plugins/researcher/index.ts +11 -0
  26. package/src/bundled-plugins/researcher/researcher.ts +226 -0
  27. package/src/bundled-plugins/researcher/skills/general.ts +105 -0
  28. package/src/bundled-plugins/researcher/write-report.ts +107 -0
  29. package/src/bundled-plugins/reviewer/reviewer.ts +29 -11
  30. package/src/bundled-plugins/reviewer/skills/data-review.ts +77 -0
  31. package/src/bundled-plugins/reviewer/skills/doc-review.ts +79 -0
  32. package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
  33. package/src/bundled-plugins/reviewer/skills/plan-review.ts +64 -0
  34. package/src/bundled-plugins/reviewer/skills/security-audit.ts +70 -0
  35. package/src/bundled-plugins/reviewer/skills/writing-review.ts +63 -0
  36. package/src/bundled-plugins/scout/scout.ts +2 -0
  37. package/src/bundled-plugins/security/policies/prompt-injection.ts +8 -4
  38. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +3 -2
  39. package/src/channels/adapters/discord-bot.ts +38 -11
  40. package/src/channels/adapters/github/inbound.ts +74 -9
  41. package/src/channels/adapters/github/index.ts +36 -11
  42. package/src/channels/adapters/github/reconcile-open-prs.ts +306 -0
  43. package/src/channels/adapters/github/review-state.ts +71 -2
  44. package/src/channels/adapters/kakaotalk-classify.ts +2 -2
  45. package/src/channels/adapters/kakaotalk.ts +2 -2
  46. package/src/channels/adapters/slack-bot-classify.ts +1 -1
  47. package/src/channels/adapters/slack-bot.ts +3 -0
  48. package/src/channels/adapters/telegram-bot.ts +3 -0
  49. package/src/channels/engagement.ts +12 -7
  50. package/src/channels/github-rereview-guard.ts +32 -8
  51. package/src/channels/github-review-claim.ts +53 -6
  52. package/src/channels/router.ts +44 -9
  53. package/src/channels/schema.ts +4 -3
  54. package/src/channels/types.ts +17 -6
  55. package/src/cli/init.ts +13 -2
  56. package/src/cli/ui.ts +64 -0
  57. package/src/config/config.ts +21 -15
  58. package/src/container/start.ts +5 -1
  59. package/src/init/dockerfile.ts +19 -56
  60. package/src/init/hatching.ts +1 -1
  61. package/src/init/index.ts +5 -1
  62. package/src/run/bundled-plugins.ts +4 -0
  63. package/src/server/index.ts +24 -5
  64. package/src/shared/host-locale.ts +27 -0
  65. package/src/shared/protocol.ts +1 -1
  66. package/src/shared/wordmark.ts +19 -0
  67. package/src/skills/typeclaw-channel-github/SKILL.md +1 -1
  68. package/src/skills/typeclaw-config/SKILL.md +32 -32
  69. package/src/skills/typeclaw-kaomoji/SKILL.md +3 -3
  70. package/src/skills/typeclaw-tunnels/SKILL.md +3 -1
  71. package/src/tui/banner.ts +19 -0
  72. package/src/tui/format.ts +34 -0
  73. package/src/tui/index.ts +121 -22
  74. package/src/tui/theme.ts +26 -1
  75. package/src/tunnels/providers/cloudflare-named.ts +15 -4
  76. package/src/tunnels/providers/cloudflare-quick.ts +15 -4
  77. package/src/tunnels/providers/cloudflared-binary.ts +11 -0
  78. package/typeclaw.schema.json +15 -7
@@ -1,4 +1,4 @@
1
- import { classifyReviewClaim } from './github-review-claim'
1
+ import { classifyReviewClaim, isPositiveWarnCloseout } from './github-review-claim'
2
2
  import type { ReviewStateResult } from './types'
3
3
 
4
4
  // The re-review stranding guard. A bot that resolves a review thread (or posts a
@@ -17,6 +17,10 @@ export type RereviewGuardInput = {
17
17
  thread: string | null
18
18
  text: string | undefined
19
19
  wantsResolve: boolean
20
+ // A mid-turn status reply (continue:true) is not the turn's receipt, so it
21
+ // suppresses the warn-tier escalation below — but never the explicit resolve,
22
+ // which is a real mutation. Mirrors the false-receipt guard's continue rule.
23
+ isContinue: boolean
20
24
  getReviewState: (req: { adapter: 'github'; workspace: string; chat: string }) => Promise<ReviewStateResult>
21
25
  workspace: string
22
26
  }
@@ -32,14 +36,19 @@ export async function evaluateRereviewGuard(input: RereviewGuardInput): Promise<
32
36
  // a close-out ack in it ("Verified — that closes it") strands the block just
33
37
  // as a thread reply would. Only the resolve ACTION needs a thread; the
34
38
  // text-claim path fires regardless (caught by isCloseoutAttempt below).
35
- if (!isCloseoutAttempt(input.wantsResolve, input.thread, input.text)) return ALLOW
39
+ if (!isCloseoutAttempt(input)) return ALLOW
36
40
 
37
41
  const state = await input.getReviewState({ adapter: 'github', workspace: input.workspace, chat: input.chat })
38
42
 
39
43
  // Fail closed: an unverifiable review state is treated as a live block, so the
40
44
  // bot never strands a re-review on a transient API failure.
41
45
  if (!state.ok) return { block: true, reason: unverifiableReason(state.error) }
42
- if (!state.selfBlocking) return ALLOW
46
+ if (!state.selfBlocking) {
47
+ if (state.reviewDecision === 'REVIEW_REQUIRED' && isPositiveWarnCloseout(input.text ?? '')) {
48
+ return { block: true, reason: INITIAL_REVIEW_REQUIRED }
49
+ }
50
+ return ALLOW
51
+ }
43
52
 
44
53
  return { block: true, reason: state.approve ? STICKY_BLOCK_APPROVE_ENABLED : STICKY_BLOCK_APPROVE_DISABLED }
45
54
  }
@@ -47,11 +56,20 @@ export async function evaluateRereviewGuard(input: RereviewGuardInput): Promise<
47
56
  // Trigger when the model asks to resolve a thread (only meaningful with a
48
57
  // thread), OR when its reply reads as a close-out/verdict claim — the latter
49
58
  // strands the block whether or not it sits in a thread, so it fires for any PR
50
- // chat. Plain discussion replies (ignore/warn) do not fire.
51
- function isCloseoutAttempt(wantsResolve: boolean, thread: string | null, text: string | undefined): boolean {
52
- if (wantsResolve && thread !== null) return true
53
- const claim = classifyReviewClaim(text ?? '')
54
- return claim === 'block-resolve' || claim === 'block-approve'
59
+ // chat. Unlike the pure false-receipt classifier, this guard has the objective
60
+ // review state available, so an approval-shaped warn reply ("looks good"/"lgtm")
61
+ // is escalated to a closeout too: it only blocks when the bot actually holds a
62
+ // live CHANGES_REQUESTED, so casual approval-shaped chatter on an unblocked PR
63
+ // still posts. Only POSITIVE warn phrases escalate — negative ones ("needs
64
+ // changes", "still needs work") re-assert a block rather than strand it, so they
65
+ // stay non-firing. `continue:true` exempts the warn escalation (mid-turn
66
+ // planning, not the receipt), but never the explicit resolve action. Plain
67
+ // `ignore` text never fires.
68
+ function isCloseoutAttempt(input: RereviewGuardInput): boolean {
69
+ if (input.wantsResolve && input.thread !== null) return true
70
+ const claim = classifyReviewClaim(input.text ?? '')
71
+ if (claim === 'block-resolve' || claim === 'block-approve') return true
72
+ return !input.isContinue && isPositiveWarnCloseout(input.text ?? '')
55
73
  }
56
74
 
57
75
  function unverifiableReason(error: string): string {
@@ -74,3 +92,9 @@ const STICKY_BLOCK_APPROVE_DISABLED =
74
92
  'dismiss your prior review via ' +
75
93
  '`gh api -X PUT /repos/<owner>/<repo>/pulls/<N>/reviews/<review_id>/dismissals -f message="..." -f event=DISMISS` ' +
76
94
  'if the blockers are fixed (or submit REQUEST_CHANGES if not), THEN resolve the thread / reply.'
95
+
96
+ const INITIAL_REVIEW_REQUIRED =
97
+ 'This PR still requires a formal GitHub review. A flat `LGTM` / `looks good` PR comment does not create ' +
98
+ 'review state, so it leaves the PR awaiting review. Submit the reviewer verdict via ' +
99
+ '`gh api -X POST /repos/<owner>/<repo>/pulls/<N>/reviews` with event `APPROVE` when approval is enabled, ' +
100
+ 'or event `COMMENT` when approval is disabled, then narrate only if needed.'
@@ -41,19 +41,26 @@ const BLOCK_RESOLVE: readonly RegExp[] = [
41
41
  /\b(thanks,?|fixed,?) (looks )?resolved\b/,
42
42
  ]
43
43
 
44
- // Casual phrasing that might be chatter, not a formal close-out: allow + nudge.
45
- const WARN: readonly RegExp[] = [
44
+ // Approval/resolve-shaped warn phrases: casual chatter that, on a PR the bot is
45
+ // still blocking, READS as a close-out and so can strand the block. Split out so
46
+ // the re-review guard can escalate only these — never the negative warn phrases
47
+ // below, which re-assert a block rather than strand it.
48
+ const WARN_POSITIVE_CLOSEOUT: readonly RegExp[] = [
46
49
  /\blgtm\b/,
47
50
  /\blooks good\b/,
48
51
  /\blooks fine\b/,
49
52
  /\bseems fine\b/,
50
53
  /\bshould be (fine|good)\b/,
51
- /\bneeds changes\b/,
52
- /\bstill needs work\b/,
53
54
  /\blooks resolved\b/,
54
55
  /\bseems resolved\b/,
55
56
  ]
56
57
 
58
+ // Negative warn phrases re-assert a block ("not done yet") instead of closing it
59
+ // out, so they are NOT close-out attempts — the re-review guard must ignore them.
60
+ const WARN_NEGATIVE: readonly RegExp[] = [/\bneeds changes\b/, /\bstill needs work\b/]
61
+
62
+ const WARN: readonly RegExp[] = [...WARN_POSITIVE_CLOSEOUT, ...WARN_NEGATIVE]
63
+
57
64
  // Negation / future-intent / past-reference markers DEMOTE a positive match to
58
65
  // ignore. Blocking "I haven't approved" / "I'll approve" / "approved it earlier"
59
66
  // (answering a question) is the worst false-positive class, so it is checked first.
@@ -61,15 +68,40 @@ const DEMOTE_TO_IGNORE: readonly RegExp[] = [
61
68
  /\b(haven'?t|have not|did ?n'?t|did not|not yet|never)\b[^.!?]*\b(approv|request|resolv|block)/,
62
69
  /\b(can'?t|cannot|won'?t|will not|wouldn'?t)\b[^.!?]*\b(approv|request|resolv|block)/,
63
70
  /\bnot (approved|resolved|blocked|requesting)\b/,
71
+ /\b(not|no longer|hardly|barely)\b[^.!?]*\b(lgtm|looks good|looks fine|seems fine|should be (fine|good)|looks resolved|seems resolved)\b/,
64
72
  /\b(i'?ll|i will|going to|gonna|about to|planning to)\b[^.!?]*\b(approv|review|request|resolv)/,
65
73
  /\b(approved|resolved|requested changes)\b[^.!?]*\b(earlier|already|yesterday|before|last (review|time)|previously)\b/,
74
+ /\b(pre|self|co|re|un|non|ai|admin|user|machine|auto) approved\b/,
66
75
  ]
67
76
 
77
+ const QUESTION_CONTEXT =
78
+ /(?:^|\b)(who|what|when|where|why|how|was|were|is|are|did|do|does|has|have|can|could|would|should)\b[^.!?]*\?/
79
+
68
80
  export function classifyReviewClaim(rawText: string): ReviewClaim {
69
- const text = normalize(rawText)
70
- if (text === '') return 'ignore'
81
+ const segments = claimSegments(rawText)
82
+ if (segments.length === 0) return 'ignore'
83
+
84
+ const claims = segments.map(classifySegment)
85
+
86
+ if (claims.includes('block-approve')) return 'block-approve'
87
+ if (claims.includes('block-request-changes')) return 'block-request-changes'
88
+ if (claims.includes('block-resolve')) return 'block-resolve'
89
+ if (claims.includes('warn')) return 'warn'
90
+ return 'ignore'
91
+ }
71
92
 
93
+ // True only for warn-tier replies whose phrasing reads as an approval/resolve
94
+ // close-out (e.g. "looks good", "lgtm"), excluding negative warn phrases like
95
+ // "needs changes" that re-assert a block. The re-review guard uses this to
96
+ // escalate just the stranding-shaped warns, not the whole warn bucket.
97
+ export function isPositiveWarnCloseout(rawText: string): boolean {
98
+ if (classifyReviewClaim(rawText) !== 'warn') return false
99
+ return claimSegments(rawText).some((segment) => WARN_POSITIVE_CLOSEOUT.some((re) => re.test(segment)))
100
+ }
101
+
102
+ function classifySegment(text: string): ReviewClaim {
72
103
  if (DEMOTE_TO_IGNORE.some((re) => re.test(text))) return 'ignore'
104
+ if (QUESTION_CONTEXT.test(text)) return 'ignore'
73
105
 
74
106
  // Block-tier wins over warn-tier: an unambiguous "approved" in a casual message
75
107
  // is still a formal claim.
@@ -80,6 +112,21 @@ export function classifyReviewClaim(rawText: string): ReviewClaim {
80
112
  return 'ignore'
81
113
  }
82
114
 
115
+ function claimSegments(text: string): string[] {
116
+ return redactQuotedAndCode(text)
117
+ .split(/(?<=[.!?])\s+|\n+/)
118
+ .map(normalize)
119
+ .filter((segment) => segment !== '')
120
+ }
121
+
122
+ function redactQuotedAndCode(text: string): string {
123
+ return text
124
+ .replace(/```[\s\S]*?```/g, ' ')
125
+ .replace(/`[^`\n]*`/g, ' ')
126
+ .replace(/"[^"\n]*"|“[^”\n]*”|‘[^’\n]*’/g, ' ')
127
+ .replace(/^\s*>.*$/gm, ' ')
128
+ }
129
+
83
130
  // Strips markdown/emoji noise so "**Approved!**" and "approved" classify alike,
84
131
  // keeping apostrophes + sentence punctuation that the negation regexes rely on.
85
132
  function normalize(text: string): string {
@@ -659,6 +659,11 @@ export type ChannelRouter = {
659
659
  removeReaction: (req: RemoveReactionRequest) => Promise<ReactionResult>
660
660
  registerTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
661
661
  unregisterTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
662
+ // Deliberately separate from registerTyping: github registers a no-op typing
663
+ // callback (no typing API) yet must stay typing-less, so "has a callback" is
664
+ // the wrong signal. autoReactOnEngage reads this to post :eyes: only as a
665
+ // fallback when no visible typing exists. Unset defaults to false.
666
+ setTypingCapability: (adapter: ChannelKey['adapter'], supported: boolean) => void
662
667
  registerChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
663
668
  unregisterChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
664
669
  // Self-identity is a per-adapter singleton (one bot account per adapter),
@@ -953,6 +958,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
953
958
  const reactionCallbacks = new Map<ChannelKey['adapter'], Set<ReactionCallback>>()
954
959
  const removeReactionCallbacks = new Map<ChannelKey['adapter'], Set<RemoveReactionCallback>>()
955
960
  const typingCallbacks = new Map<ChannelKey['adapter'], Set<TypingCallback>>()
961
+ const typingCapableAdapters = new Set<ChannelKey['adapter']>()
956
962
  const channelNameResolvers = new Map<ChannelKey['adapter'], Set<ChannelNameResolver>>()
957
963
  const membershipResolvers = new Map<ChannelKey['adapter'], Set<MembershipResolver>>()
958
964
  const selfIdentityResolvers = new Map<ChannelKey['adapter'], ChannelSelfIdentityResolver>()
@@ -1422,8 +1428,20 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1422
1428
  unsubTypingActivity: null,
1423
1429
  unsubTodoOutcome: null,
1424
1430
  }
1431
+ // Tracks the `turnSeq` a provider-error notice was last POSTED for, so the
1432
+ // channel surfaces at most one notice per turn. The upstream SDK retries
1433
+ // internally, and each retry emits its own `message_end` with
1434
+ // `stopReason: 'error'` — without this gate a single failing turn posts N
1435
+ // identical "⚠️ upstream provider failed" notices (one per retry). Logs
1436
+ // still record every attempt; only the user-facing notice is deduped.
1437
+ let lastProviderErrorNoticeTurn: number | undefined
1425
1438
  live.unsubProviderErrors = subscribeProviderErrors(created.session, (err) => {
1426
1439
  logger.error(`[channels] ${live.keyId}: LLM call failed: ${err.message}`)
1440
+ // Suppress duplicate notices for the SAME turn (retry storm). Set the
1441
+ // marker BEFORE the async send so a synchronous burst of retry events
1442
+ // can't each slip past the check and enqueue their own notice.
1443
+ if (lastProviderErrorNoticeTurn === live.turnSeq) return
1444
+ lastProviderErrorNoticeTurn = live.turnSeq
1427
1445
  // A provider soft-error (rate/usage limit, billing, malformed response)
1428
1446
  // ends the turn with no assistant text, so the human otherwise sees
1429
1447
  // silence. Surface the REDACTED `safeMessage` (never the raw provider
@@ -1501,7 +1519,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1501
1519
  logger.error(`[channels] ${keyId}: ensureLive failed: ${describe(err)}`)
1502
1520
  throw err
1503
1521
  } finally {
1504
- creating.delete(keyId)
1522
+ // Owner-checked delete: only clear the in-flight marker if it still points
1523
+ // at THIS promise. A watchdog timeout can orphan a slow creation whose
1524
+ // `finally` runs while a later inbound has already installed its own
1525
+ // `creating` entry for the same key; an unconditional delete would drop
1526
+ // that newer entry and let a third inbound cold-start a duplicate session
1527
+ // (observed: 3 concurrent sessions approving the same PR).
1528
+ if (creating.get(keyId) === promise) creating.delete(keyId)
1505
1529
  }
1506
1530
  }
1507
1531
 
@@ -2435,13 +2459,18 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2435
2459
  }
2436
2460
 
2437
2461
  // Best-effort acknowledgment: drop an :eyes: on the triggering inbound the
2438
- // moment we decide to engage, replacing the old "On it" ack comment on
2439
- // GitHub. Fire-and-forget so a reaction failure (missing permission, the
2440
- // adapter not supporting reactions, a transient API error) can NEVER block
2441
- // engagement, enqueueing, or the agent's actual reply. No reactionRef =
2442
- // nothing reactable (synthetic inbounds, reaction-less adapters) = silent skip.
2462
+ // moment we decide to engage but ONLY when the channel has no visible
2463
+ // "typing…" indicator. Where typing renders (slack/discord/telegram) the
2464
+ // heartbeat already signals "the bot is working", so the reaction would be
2465
+ // redundant noise; the :eyes: is the fallback ack for typing-less channels
2466
+ // (github, kakaotalk), replacing the old "On it" comment on GitHub.
2467
+ // Fire-and-forget so a reaction failure (missing permission, the adapter not
2468
+ // supporting reactions, a transient API error) can NEVER block engagement,
2469
+ // enqueueing, or the agent's actual reply. No reactionRef = nothing reactable
2470
+ // (synthetic inbounds, reaction-less adapters) = silent skip.
2443
2471
  const autoReactOnEngage = (event: InboundMessage): Promise<ReactionRef | null> | null => {
2444
2472
  if (event.reactionRef === undefined) return null
2473
+ if (typingCapableAdapters.has(event.adapter)) return null
2445
2474
  const addResult = react({
2446
2475
  adapter: event.adapter,
2447
2476
  workspace: event.workspace,
@@ -2510,6 +2539,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2510
2539
  typingCallbacks.get(adapter)?.delete(cb)
2511
2540
  }
2512
2541
 
2542
+ const setTypingCapability = (adapter: ChannelKey['adapter'], supported: boolean): void => {
2543
+ if (supported) typingCapableAdapters.add(adapter)
2544
+ else typingCapableAdapters.delete(adapter)
2545
+ }
2546
+
2513
2547
  const registerChannelNameResolver = (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver): void => {
2514
2548
  let set = channelNameResolvers.get(adapter)
2515
2549
  if (!set) {
@@ -3538,6 +3572,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3538
3572
  removeReaction,
3539
3573
  registerTyping,
3540
3574
  unregisterTyping,
3575
+ setTypingCapability,
3541
3576
  registerChannelNameResolver,
3542
3577
  unregisterChannelNameResolver,
3543
3578
  registerSelfIdentity,
@@ -3694,7 +3729,7 @@ function composeTurnPrompt(
3694
3729
  // sections used for actual conversation content (`## Recent context`,
3695
3730
  // `## Current message`). Without the fencing, models — especially
3696
3731
  // persona-rich ones like Kimi — read the heading as a human-authored
3697
- // instruction and reply to it ("알겠습니다, 대화 여기까지 할게요"). The
3732
+ // instruction and reply to it (e.g. "Understood, I'll stop here"). The
3698
3733
  // bracketed marker plus the explicit "Do not acknowledge or reply to
3699
3734
  // this notice" line is the trust boundary that prevents this. New
3700
3735
  // runtime notices (rate-limit, schema-mismatch, abort signals, etc.)
@@ -3923,8 +3958,8 @@ export type QuoteAnchorTarget = {
3923
3958
  // Slack/Discord/Telegram attachments). The quote anchor is a UX
3924
3959
  // affordance pointing the human at *their words* — quoting a sticker as
3925
3960
  // `> Alice: [KakaoTalk attachment #1: sticker name=...]`
3926
- // is noise, and for mixed inbounds like `사진 [KakaoTalk message with
3927
- // photo 1254x1254 ...]` the human only wrote `사진`, so the placeholder
3961
+ // is noise, and for mixed inbounds like `<caption> [KakaoTalk message with
3962
+ // photo 1254x1254 ...]` the human only wrote the caption, so the placeholder
3928
3963
  // is the wrong thing to surface. The callsite (captureQuoteCandidate)
3929
3964
  // treats an empty residue as "no quote anchor"; mixed inbounds keep the
3930
3965
  // human-written portion. renderQuoteAnchor later collapses whitespace
@@ -192,8 +192,9 @@ export const SEEDED_GITHUB_EVENT_ALLOWLISTS: readonly (readonly string[])[] = [
192
192
  // prefix is implied by this field living under the review config); `off` is the
193
193
  // disable sentinel, matching the `engagement.stickiness: 'off'` convention:
194
194
  // - 'review_requested' — review only when the bot is requested (default)
195
- // - 'opened' — review every non-draft PR as soon as it opens; draft
196
- // PRs are skipped until an explicit review_requested
195
+ // - 'opened' — review every non-draft PR as soon as it opens; a draft
196
+ // PR wakes no session and is reviewed once it turns ready
197
+ // (ready_for_review) or the bot is explicitly requested
197
198
  // - 'off' — disable code review entirely
198
199
  export const GITHUB_REVIEW_ON_VALUES = ['review_requested', 'opened', 'off'] as const
199
200
 
@@ -242,7 +243,7 @@ const githubChannelSchema = adapterSchema.extend({
242
243
  // KakaoTalk uses the same shape as every other adapter. There used to be an
243
244
  // `autoMarkRead` opt-in here; the adapter now fires a LOCO NOTIREAD ack on
244
245
  // every inbound MSG event unconditionally (see kakaotalk.ts) so the sender's
245
- // 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.
246
247
  // Existing configs with `autoMarkRead: <bool>` continue to parse — Zod's
247
248
  // default `.object()` strips unknown keys silently — but the field has no
248
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
@@ -395,19 +401,24 @@ export type ReviewStateRequest = {
395
401
  chat: string
396
402
  }
397
403
 
398
- // `selfBlocking` is the answer the guard acts on: true means the bot's latest
399
- // effective formal review is its own CHANGES_REQUESTED (COMMENTED reviews are
400
- // ignored — they never clear the sticky block, GitHub's own rule). `approve`
401
- // mirrors `channels.github.review.approve` so the guard's denial text can tell
402
- // the model whether to land a fresh APPROVE or to DISMISS its prior review.
404
+ // `selfBlocking` is the answer the guard acts on for re-reviews: true means the
405
+ // bot's latest effective formal review is its own CHANGES_REQUESTED (COMMENTED
406
+ // reviews are ignored — they never clear the sticky block, GitHub's own rule).
407
+ // `reviewDecision` is GitHub's aggregate PR review status when GraphQL can
408
+ // provide it; REVIEW_REQUIRED means an approval-shaped flat comment would still
409
+ // leave the PR awaiting a formal review. `approve` mirrors
410
+ // `channels.github.review.approve` so the guard's denial text can tell the model
411
+ // whether to land a fresh APPROVE or to DISMISS its prior review.
403
412
  //
404
413
  // On `ok: false` the caller MUST fail closed: an unverifiable review state is
405
414
  // treated like a live block, so the bot never claims close-out when the runtime
406
415
  // could not confirm the platform-side verdict.
407
416
  export type ReviewStateResult =
408
- | { ok: true; selfBlocking: boolean; approve: boolean }
417
+ | { ok: true; selfBlocking: boolean; approve: boolean; reviewDecision?: GithubReviewDecision }
409
418
  | { ok: false; error: string; code?: 'unsupported' | 'not-found' | 'permission-denied' | 'transient' }
410
419
 
420
+ export type GithubReviewDecision = 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED'
421
+
411
422
  // Registered per-adapter on the ChannelRouter, last-write-wins like the
412
423
  // review-thread resolver. Adapters that never register one make `getReviewState`
413
424
  // answer `unsupported`.
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 }