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.
- package/package.json +1 -1
- package/src/agent/index.ts +37 -5
- package/src/agent/loop-guard.ts +112 -26
- package/src/agent/plugin-tools.ts +102 -41
- package/src/agent/session-origin.ts +3 -3
- package/src/agent/subagents.ts +7 -0
- package/src/agent/system-prompt.ts +29 -4
- package/src/agent/tools/channel-send.ts +1 -1
- package/src/agent/tools/spawn-subagent.ts +21 -0
- package/src/agent/tools/subagent-output.ts +7 -3
- package/src/agent/tools/wikipedia.ts +1 -1
- package/src/bundled-plugins/explorer/explorer.ts +2 -0
- package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +74 -0
- package/src/bundled-plugins/github-cli-auth/effective-approval.ts +98 -0
- package/src/bundled-plugins/github-cli-auth/gh-review-inline-detect.ts +130 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +27 -2
- package/src/bundled-plugins/github-cli-auth/review-recorder.ts +12 -4
- package/src/bundled-plugins/memory/memory-logger.ts +3 -3
- package/src/bundled-plugins/operator/operator.ts +2 -0
- package/src/bundled-plugins/planner/index.ts +11 -0
- package/src/bundled-plugins/planner/planner.ts +282 -0
- package/src/bundled-plugins/planner/skills/general.ts +65 -0
- package/src/bundled-plugins/planner/skills/project.ts +69 -0
- package/src/bundled-plugins/researcher/index.ts +11 -0
- package/src/bundled-plugins/researcher/researcher.ts +226 -0
- package/src/bundled-plugins/researcher/skills/general.ts +105 -0
- package/src/bundled-plugins/researcher/write-report.ts +107 -0
- package/src/bundled-plugins/reviewer/reviewer.ts +26 -8
- package/src/bundled-plugins/reviewer/skills/data-review.ts +77 -0
- package/src/bundled-plugins/reviewer/skills/doc-review.ts +79 -0
- package/src/bundled-plugins/reviewer/skills/plan-review.ts +64 -0
- package/src/bundled-plugins/reviewer/skills/security-audit.ts +70 -0
- package/src/bundled-plugins/reviewer/skills/writing-review.ts +63 -0
- package/src/bundled-plugins/scout/scout.ts +2 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +8 -4
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +3 -2
- package/src/channels/adapters/discord-bot.ts +38 -11
- package/src/channels/adapters/github/inbound.ts +68 -4
- package/src/channels/adapters/kakaotalk-classify.ts +2 -2
- package/src/channels/adapters/kakaotalk.ts +2 -2
- package/src/channels/adapters/slack-bot-classify.ts +1 -1
- package/src/channels/adapters/slack-bot.ts +3 -0
- package/src/channels/adapters/telegram-bot.ts +3 -0
- package/src/channels/engagement.ts +12 -7
- package/src/channels/router.ts +32 -9
- package/src/channels/schema.ts +1 -1
- package/src/channels/types.ts +6 -0
- package/src/cli/init.ts +13 -2
- package/src/cli/ui.ts +64 -0
- package/src/config/config.ts +21 -15
- package/src/container/start.ts +5 -1
- package/src/init/dockerfile.ts +19 -56
- package/src/init/hatching.ts +1 -1
- package/src/init/index.ts +5 -1
- package/src/run/bundled-plugins.ts +4 -0
- package/src/server/index.ts +24 -5
- package/src/shared/host-locale.ts +27 -0
- package/src/shared/protocol.ts +1 -1
- package/src/shared/wordmark.ts +19 -0
- package/src/skills/typeclaw-config/SKILL.md +32 -32
- package/src/skills/typeclaw-kaomoji/SKILL.md +3 -3
- package/src/skills/typeclaw-tunnels/SKILL.md +3 -1
- package/src/tui/banner.ts +19 -0
- package/src/tui/format.ts +34 -0
- package/src/tui/index.ts +121 -22
- package/src/tui/theme.ts +26 -1
- package/src/tunnels/providers/cloudflare-named.ts +15 -4
- package/src/tunnels/providers/cloudflare-quick.ts +15 -4
- package/src/tunnels/providers/cloudflared-binary.ts +11 -0
- 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. "
|
|
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 "
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
180
|
-
// "
|
|
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
|
//
|
package/src/channels/router.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
2451
|
-
//
|
|
2452
|
-
//
|
|
2453
|
-
//
|
|
2454
|
-
//
|
|
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 ("
|
|
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
|
|
3939
|
-
// photo 1254x1254 ...]` the human only wrote
|
|
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
|
package/src/channels/schema.ts
CHANGED
|
@@ -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" (
|
|
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
|
package/src/channels/types.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
package/src/config/config.ts
CHANGED
|
@@ -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
|
|
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.
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
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
|
-
// `
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
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.
|
package/src/container/start.ts
CHANGED
|
@@ -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, {
|
|
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 }
|
package/src/init/dockerfile.ts
CHANGED
|
@@ -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' | '
|
|
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
|
|
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:
|
|
1514
|
-
cloudflared:
|
|
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'
|
|
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
|
|
package/src/init/hatching.ts
CHANGED
|
@@ -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
|
|
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, {
|
|
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
|
]
|