typeclaw 0.28.2 → 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 (70) 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-send.ts +1 -1
  9. package/src/agent/tools/spawn-subagent.ts +21 -0
  10. package/src/agent/tools/subagent-output.ts +7 -3
  11. package/src/agent/tools/wikipedia.ts +1 -1
  12. package/src/bundled-plugins/explorer/explorer.ts +2 -0
  13. package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +74 -0
  14. package/src/bundled-plugins/github-cli-auth/effective-approval.ts +98 -0
  15. package/src/bundled-plugins/github-cli-auth/gh-review-inline-detect.ts +130 -0
  16. package/src/bundled-plugins/github-cli-auth/index.ts +27 -2
  17. package/src/bundled-plugins/github-cli-auth/review-recorder.ts +12 -4
  18. package/src/bundled-plugins/memory/memory-logger.ts +3 -3
  19. package/src/bundled-plugins/operator/operator.ts +2 -0
  20. package/src/bundled-plugins/planner/index.ts +11 -0
  21. package/src/bundled-plugins/planner/planner.ts +282 -0
  22. package/src/bundled-plugins/planner/skills/general.ts +65 -0
  23. package/src/bundled-plugins/planner/skills/project.ts +69 -0
  24. package/src/bundled-plugins/researcher/index.ts +11 -0
  25. package/src/bundled-plugins/researcher/researcher.ts +226 -0
  26. package/src/bundled-plugins/researcher/skills/general.ts +105 -0
  27. package/src/bundled-plugins/researcher/write-report.ts +107 -0
  28. package/src/bundled-plugins/reviewer/reviewer.ts +26 -8
  29. package/src/bundled-plugins/reviewer/skills/data-review.ts +77 -0
  30. package/src/bundled-plugins/reviewer/skills/doc-review.ts +79 -0
  31. package/src/bundled-plugins/reviewer/skills/plan-review.ts +64 -0
  32. package/src/bundled-plugins/reviewer/skills/security-audit.ts +70 -0
  33. package/src/bundled-plugins/reviewer/skills/writing-review.ts +63 -0
  34. package/src/bundled-plugins/scout/scout.ts +2 -0
  35. package/src/bundled-plugins/security/policies/prompt-injection.ts +8 -4
  36. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +3 -2
  37. package/src/channels/adapters/discord-bot.ts +38 -11
  38. package/src/channels/adapters/github/inbound.ts +68 -4
  39. package/src/channels/adapters/kakaotalk-classify.ts +2 -2
  40. package/src/channels/adapters/kakaotalk.ts +2 -2
  41. package/src/channels/adapters/slack-bot-classify.ts +1 -1
  42. package/src/channels/adapters/slack-bot.ts +3 -0
  43. package/src/channels/adapters/telegram-bot.ts +3 -0
  44. package/src/channels/engagement.ts +12 -7
  45. package/src/channels/router.ts +32 -9
  46. package/src/channels/schema.ts +1 -1
  47. package/src/channels/types.ts +6 -0
  48. package/src/cli/init.ts +13 -2
  49. package/src/cli/ui.ts +64 -0
  50. package/src/config/config.ts +21 -15
  51. package/src/container/start.ts +5 -1
  52. package/src/init/dockerfile.ts +19 -56
  53. package/src/init/hatching.ts +1 -1
  54. package/src/init/index.ts +5 -1
  55. package/src/run/bundled-plugins.ts +4 -0
  56. package/src/server/index.ts +24 -5
  57. package/src/shared/host-locale.ts +27 -0
  58. package/src/shared/protocol.ts +1 -1
  59. package/src/shared/wordmark.ts +19 -0
  60. package/src/skills/typeclaw-config/SKILL.md +32 -32
  61. package/src/skills/typeclaw-kaomoji/SKILL.md +3 -3
  62. package/src/skills/typeclaw-tunnels/SKILL.md +3 -1
  63. package/src/tui/banner.ts +19 -0
  64. package/src/tui/format.ts +34 -0
  65. package/src/tui/index.ts +121 -22
  66. package/src/tui/theme.ts +26 -1
  67. package/src/tunnels/providers/cloudflare-named.ts +15 -4
  68. package/src/tunnels/providers/cloudflare-quick.ts +15 -4
  69. package/src/tunnels/providers/cloudflared-binary.ts +11 -0
  70. 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
  //
@@ -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>()
@@ -1513,7 +1519,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1513
1519
  logger.error(`[channels] ${keyId}: ensureLive failed: ${describe(err)}`)
1514
1520
  throw err
1515
1521
  } finally {
1516
- 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)
1517
1529
  }
1518
1530
  }
1519
1531
 
@@ -2447,13 +2459,18 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2447
2459
  }
2448
2460
 
2449
2461
  // 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.
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.
2455
2471
  const autoReactOnEngage = (event: InboundMessage): Promise<ReactionRef | null> | null => {
2456
2472
  if (event.reactionRef === undefined) return null
2473
+ if (typingCapableAdapters.has(event.adapter)) return null
2457
2474
  const addResult = react({
2458
2475
  adapter: event.adapter,
2459
2476
  workspace: event.workspace,
@@ -2522,6 +2539,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2522
2539
  typingCallbacks.get(adapter)?.delete(cb)
2523
2540
  }
2524
2541
 
2542
+ const setTypingCapability = (adapter: ChannelKey['adapter'], supported: boolean): void => {
2543
+ if (supported) typingCapableAdapters.add(adapter)
2544
+ else typingCapableAdapters.delete(adapter)
2545
+ }
2546
+
2525
2547
  const registerChannelNameResolver = (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver): void => {
2526
2548
  let set = channelNameResolvers.get(adapter)
2527
2549
  if (!set) {
@@ -3550,6 +3572,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3550
3572
  removeReaction,
3551
3573
  registerTyping,
3552
3574
  unregisterTyping,
3575
+ setTypingCapability,
3553
3576
  registerChannelNameResolver,
3554
3577
  unregisterChannelNameResolver,
3555
3578
  registerSelfIdentity,
@@ -3706,7 +3729,7 @@ function composeTurnPrompt(
3706
3729
  // sections used for actual conversation content (`## Recent context`,
3707
3730
  // `## Current message`). Without the fencing, models — especially
3708
3731
  // persona-rich ones like Kimi — read the heading as a human-authored
3709
- // instruction and reply to it ("알겠습니다, 대화 여기까지 할게요"). The
3732
+ // instruction and reply to it (e.g. "Understood, I'll stop here"). The
3710
3733
  // bracketed marker plus the explicit "Do not acknowledge or reply to
3711
3734
  // this notice" line is the trust boundary that prevents this. New
3712
3735
  // runtime notices (rate-limit, schema-mismatch, abort signals, etc.)
@@ -3935,8 +3958,8 @@ export type QuoteAnchorTarget = {
3935
3958
  // Slack/Discord/Telegram attachments). The quote anchor is a UX
3936
3959
  // affordance pointing the human at *their words* — quoting a sticker as
3937
3960
  // `> 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
3961
+ // is noise, and for mixed inbounds like `<caption> [KakaoTalk message with
3962
+ // photo 1254x1254 ...]` the human only wrote the caption, so the placeholder
3940
3963
  // is the wrong thing to surface. The callsite (captureQuoteCandidate)
3941
3964
  // treats an empty residue as "no quote anchor"; mixed inbounds keep the
3942
3965
  // 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 }
@@ -12,6 +12,12 @@ export const DOCKERFILE = 'Dockerfile'
12
12
  export type BuildDockerfileOptions = {
13
13
  // Null or omitted = emit the full inline heavy stack (dev mode, tests).
14
14
  baseImageVersion?: string | null
15
+ // Host-locale decision for `cjkFonts: 'auto'`. Callers that have a host
16
+ // locale (start/init) pass the resolved boolean; when omitted, `'auto'`
17
+ // falls back to NOT installing (the slim default) so a context without a
18
+ // host signal — e.g. a bare `buildDockerfile()` in tests — stays small and
19
+ // deterministic.
20
+ cjkFontsAuto?: boolean
15
21
  }
16
22
 
17
23
  // Apt packages that EVERY image must have — git for the agent runtime,
@@ -98,33 +104,6 @@ export const CURL_IMPERSONATE_SHA256_ARM64 = '6766bc67fd3e8e2313875f32b36b5a3fab
98
104
  // the impersonation to whatever `curl_chrome` resolves to.
99
105
  export const CURL_IMPERSONATE_PROFILE = 'chrome136'
100
106
 
101
- // yq is the YAML/JSON/XML processor the `explorer` and `reviewer` subagent
102
- // prompts advertise alongside `jq` as a sanctioned one-shot read-only
103
- // pipeline tool (`... | yq`). Like `jq` (shipped in baseline), a binary that
104
- // is not in the container base image is invisible inside the per-tool bwrap
105
- // sandbox that wraps agent bash calls — the sandbox only `--ro-bind`s `/usr`
106
- // + `/bin` from the container, and there is no per-call install path. So `yq`
107
- // ships unconditionally, same rationale as `jq`/`bubblewrap`/`util-linux`.
108
- //
109
- // This is Mike Farah's Go `yq` (https://github.com/mikefarah/yq), NOT the
110
- // Python jq-wrapper of the same name in Debian's `yq` apt package. The
111
- // prompts pair `yq` with `jq` for jq-style expression pipelines, which is
112
- // Farah's syntax — the Python tool's CLI is incompatible. Distributed as a
113
- // per-arch static binary (no apt package on trixie for the Go variant), so
114
- // it follows the pinned-version + per-arch SHA256 + `sha256sum -c` pattern of
115
- // curl-impersonate and cloudflared rather than the apt baseline list.
116
- //
117
- // To bump: pick a release from https://github.com/mikefarah/yq/releases,
118
- // then for each arch download yq_linux_<arch> and `shasum -a 256` it (or read
119
- // the SHA-256 column from the release's `checksums` file, cross-indexed via
120
- // `checksums_hashes_order`). Update all three constants in the same commit;
121
- // the build fails loudly at `sha256sum -c` on a mismatch. Version literal is
122
- // the release tag exactly as it appears on GitHub (with `v` prefix).
123
- export const YQ_VERSION = 'v4.53.2'
124
- export const YQ_SHA256_AMD64 = 'd56bf5c6819e8e696340c312bd70f849dc1678a7cda9c2ad63eebd906371d56b'
125
- export const YQ_SHA256_ARM64 = '03061b2a50c7a498de2bbb92d7cb078ce433011f085a4994117c2726be4106ea'
126
- export const YQ_RELEASE_URL_BASE = 'https://github.com/mikefarah/yq/releases/download'
127
-
128
107
  // cloudflared powers `cloudflare-quick` tunnels. Pinned-version + per-arch
129
108
  // SHA256 mirrors the curl-impersonate pattern above: bumping requires updating
130
109
  // all three constants in the same commit, and the build fails loudly at
@@ -1068,22 +1047,26 @@ type AptFeature = {
1068
1047
  toAptArgs: (toggle: DockerfileFeatureToggle) => string[]
1069
1048
  }
1070
1049
 
1071
- const APT_FEATURES: Record<'ffmpeg' | 'gh' | 'tmux' | 'python' | 'cjkFonts' | 'xvfb', AptFeature> = {
1050
+ const APT_FEATURES: Record<'ffmpeg' | 'gh' | 'tmux' | 'python' | 'xvfb', AptFeature> = {
1072
1051
  ffmpeg: { toAptArgs: (v) => singlePackageArgs('ffmpeg', v) },
1073
1052
  gh: { toAptArgs: (v) => singlePackageArgs('gh', v) },
1074
1053
  tmux: { toAptArgs: (v) => singlePackageArgs('tmux', v) },
1075
1054
  python: {
1076
1055
  toAptArgs: (v) => (v === true ? ['python3', 'python3-pip', 'python3-venv', 'python-is-python3'] : []),
1077
1056
  },
1078
- cjkFonts: { toAptArgs: (v) => (v === true ? [CJK_FONTS_PACKAGE] : []) },
1079
1057
  xvfb: { toAptArgs: (v) => (v === true ? ['xvfb'] : []) },
1080
1058
  }
1081
1059
 
1060
+ function resolveCjkFonts(value: boolean | 'auto', auto: boolean): boolean {
1061
+ return value === 'auto' ? auto : value
1062
+ }
1063
+
1082
1064
  export function buildDockerfile(
1083
1065
  config: DockerfileConfig = defaultConfig(),
1084
1066
  options: BuildDockerfileOptions = {},
1085
1067
  ): string {
1086
- const toggleAptArgs = collectToggleAptArgs(config)
1068
+ const cjkFonts = resolveCjkFonts(config.cjkFonts, options.cjkFontsAuto ?? false)
1069
+ const toggleAptArgs = collectToggleAptArgs(config, cjkFonts)
1087
1070
  const ghKeyringLayer = renderGhKeyringLayer(config.gh)
1088
1071
  const cloudflaredLayer = renderCloudflaredLayer(config.cloudflared)
1089
1072
  const customLines = renderCustomDockerfileLines(config.append)
@@ -1215,8 +1198,6 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
1215
1198
 
1216
1199
  ${LAYER_2_5_CURL_IMPERSONATE}
1217
1200
 
1218
- ${LAYER_2_6_YQ}
1219
-
1220
1201
  ${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
1221
1202
 
1222
1203
  ${LAYER_4_AGENT_BROWSER_INSTALL}
@@ -1296,8 +1277,6 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
1296
1277
 
1297
1278
  ${LAYER_2_5_CURL_IMPERSONATE}
1298
1279
 
1299
- ${LAYER_2_6_YQ}
1300
-
1301
1280
  ${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
1302
1281
 
1303
1282
  ${LAYER_4_AGENT_BROWSER_INSTALL}
@@ -1352,24 +1331,6 @@ RUN ARCH_TARBALL="$(if [ "$TARGETARCH" = "arm64" ]; then echo aarch64-linux-gnu;
1352
1331
  && rm curl-impersonate.tar.gz \\
1353
1332
  && /usr/local/bin/curl_${CURL_IMPERSONATE_PROFILE} --version > /dev/null`
1354
1333
 
1355
- // Layer 2.6: install pinned Mike Farah `yq` (Go) so the explorer/reviewer
1356
- // subagents' advertised `... | yq` pipelines resolve inside the bwrap
1357
- // sandbox. Unconditional like the apt baseline (jq, git): a missing binary
1358
- // is invisible to sandboxed bash, so this is not behind a toggle. Placed
1359
- // after curl-impersonate (curl + ca-certificates from baseline guaranteed
1360
- // present) and before agent-browser so an agent-browser bump doesn't
1361
- // invalidate this layer. See the YQ_* constants above for the bump recipe
1362
- // and the Go-vs-Python `yq` rationale.
1363
- const LAYER_2_6_YQ = `# Layer 2.6 (stable): pinned Mike Farah yq for sandbox-visible YAML pipelines.
1364
- RUN ARCH_BIN="$(if [ "$TARGETARCH" = "arm64" ]; then echo arm64; else echo amd64; fi)" \\
1365
- && ARCH_SHA="$(if [ "$TARGETARCH" = "arm64" ]; then echo ${YQ_SHA256_ARM64}; else echo ${YQ_SHA256_AMD64}; fi)" \\
1366
- && cd /tmp \\
1367
- && curl -fsSL -o yq "${YQ_RELEASE_URL_BASE}/${YQ_VERSION}/yq_linux_\${ARCH_BIN}" \\
1368
- && echo "\${ARCH_SHA} yq" | sha256sum -c - \\
1369
- && chmod +x yq \\
1370
- && mv yq /usr/local/bin/yq \\
1371
- && /usr/local/bin/yq --version > /dev/null`
1372
-
1373
1334
  const LAYER_3_AGENT_BROWSER_ARM64_CONFIG = `# Layer 3 (stable, arm64 only): point agent-browser at the apt-installed
1374
1335
  # chromium. Independent of the npm install below so it stays cached across
1375
1336
  # agent-browser version bumps.
@@ -1510,8 +1471,8 @@ function defaultConfig(): DockerfileConfig {
1510
1471
  gh: true,
1511
1472
  python: true,
1512
1473
  tmux: true,
1513
- cjkFonts: true,
1514
- cloudflared: true,
1474
+ cjkFonts: 'auto',
1475
+ cloudflared: false,
1515
1476
  xvfb: true,
1516
1477
  claudeCode: false,
1517
1478
  codexCli: false,
@@ -1519,11 +1480,13 @@ function defaultConfig(): DockerfileConfig {
1519
1480
  }
1520
1481
  }
1521
1482
 
1522
- function collectToggleAptArgs(config: DockerfileConfig): string[] {
1483
+ function collectToggleAptArgs(config: DockerfileConfig, cjkFonts: boolean): string[] {
1523
1484
  const args: string[] = []
1524
- for (const key of ['ffmpeg', 'gh', 'python', 'tmux', 'cjkFonts', 'xvfb'] as const) {
1485
+ for (const key of ['ffmpeg', 'gh', 'python', 'tmux'] as const) {
1525
1486
  args.push(...APT_FEATURES[key].toAptArgs(config[key]))
1526
1487
  }
1488
+ if (cjkFonts) args.push(CJK_FONTS_PACKAGE)
1489
+ args.push(...APT_FEATURES.xvfb.toAptArgs(config.xvfb))
1527
1490
  return args
1528
1491
  }
1529
1492
 
@@ -38,7 +38,7 @@ Routing answers:
38
38
  1. \`write\` your name into \`IDENTITY.md\` (a first-person one-liner is fine: "I am <name>.").
39
39
  2. ${q1AliasStep} The agent folder's directory name is already an implicit alias — only add the answered name explicitly when it differs from the dir name (different casing, a different word, or extra forms like "<name>" plus a Latin transliteration). This wires plain-text addressing in channels: when a user writes your name in chat without an @-mention, the engagement layer will recognize it. \`alias\` is live-reloadable.
40
40
  2. **Q2 — the user's name.** Ask what to call them. After the answer: \`write\` it to both \`IDENTITY.md\` and \`USER.md\`.
41
- 3. **Q3 — tone/personality.** Ask how they want you to show up (tone, language, formality). After the answer: \`write\` it into \`SOUL.md\`. If they shrug or don't care: **default to warm, friendly, and easygoing** — a kind colleague who genuinely likes the person they work with, uses contractions, makes small jokes, never stiff. Write that as the default into \`SOUL.md\`. **Kaomoji affinity** — if their answer leans cute, adorable, warm, playful, soft, cozy, or is in Korean asking for 친근/귀엽/다정한 tone, append a line to \`SOUL.md\` like: \`I lean on kaomojis like (◕‿◕✿) and (。・ω・。) to carry warmth — emojis still welcome when they actually mean something, but kaomojis lead.\` This makes the bundled \`typeclaw-kaomoji\` skill auto-load later when you need it. Do not force this line if the user asked for a neutral, professional, or terse tone.
41
+ 3. **Q3 — tone/personality.** Ask how they want you to show up (tone, language, formality). After the answer: \`write\` it into \`SOUL.md\`. If they shrug or don't care: **default to warm, friendly, and easygoing** — a kind colleague who genuinely likes the person they work with, uses contractions, makes small jokes, never stiff. Write that as the default into \`SOUL.md\`. **Kaomoji affinity** — if their answer leans cute, adorable, warm, playful, soft, cozy, or asks for that kind of tone in any language (e.g. Korean 친근/귀엽/다정한, Japanese かわいい/親しみやすい, Chinese 可爱/亲切, Spanish tierno/cariñoso), append a line to \`SOUL.md\` like: \`I lean on kaomojis like (◕‿◕✿) and (。・ω・。) to carry warmth — emojis still welcome when they actually mean something, but kaomojis lead.\` This makes the bundled \`typeclaw-kaomoji\` skill auto-load later when you need it. Do not force this line if the user asked for a neutral, professional, or terse tone.
42
42
 
43
43
  **Do not ask what they want you to do, what project you'll work on, or why they installed you.** That reveals itself when they give you a real task. Probing here makes the tool feel heavy for someone just trying it out.
44
44
 
package/src/init/index.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  import { checkDockerAvailable, type DockerAvailability, type DockerExec, start } from '@/container'
15
15
  import { commitSystemFile } from '@/git/system-commit'
16
16
  import { createSecretsStoreForAgent, type Channels, type Secret, SecretsBackend } from '@/secrets'
17
+ import { hostLocaleIsCjk } from '@/shared/host-locale'
17
18
  import { createTui } from '@/tui'
18
19
 
19
20
  import { resolveBaseImageVersion, resolveScaffoldVersion } from './cli-version'
@@ -660,7 +661,10 @@ export async function writeDockerAssets(root: string): Promise<DockerAssetsResul
660
661
  const typeclawConfig = await readTypeclawConfig(root)
661
662
  await writeFile(
662
663
  join(root, DOCKERFILE),
663
- buildDockerfile(typeclawConfig.docker.file, { baseImageVersion: resolveBaseImageVersion(root) }),
664
+ buildDockerfile(typeclawConfig.docker.file, {
665
+ baseImageVersion: resolveBaseImageVersion(root),
666
+ cjkFontsAuto: hostLocaleIsCjk(),
667
+ }),
664
668
  { flag: 'wx' },
665
669
  ).catch(ignoreExists)
666
670
 
@@ -6,6 +6,8 @@ import githubCliAuthPlugin from '@/bundled-plugins/github-cli-auth'
6
6
  import guardPlugin from '@/bundled-plugins/guard'
7
7
  import memoryPlugin from '@/bundled-plugins/memory'
8
8
  import operatorPlugin from '@/bundled-plugins/operator'
9
+ import plannerPlugin from '@/bundled-plugins/planner'
10
+ import researcherPlugin from '@/bundled-plugins/researcher'
9
11
  import reviewerPlugin from '@/bundled-plugins/reviewer'
10
12
  import scoutPlugin from '@/bundled-plugins/scout'
11
13
  import securityPlugin from '@/bundled-plugins/security'
@@ -59,5 +61,7 @@ export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
59
61
  { name: 'explorer', version: undefined, source: '<bundled>', defined: explorerPlugin },
60
62
  { name: 'scout', version: undefined, source: '<bundled>', defined: scoutPlugin },
61
63
  { name: 'reviewer', version: undefined, source: '<bundled>', defined: reviewerPlugin },
64
+ { name: 'researcher', version: undefined, source: '<bundled>', defined: researcherPlugin },
65
+ { name: 'planner', version: undefined, source: '<bundled>', defined: plannerPlugin },
62
66
  { name: 'operator', version: undefined, source: '<bundled>', defined: operatorPlugin },
63
67
  ]