typeclaw 0.28.2 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent/index.ts +43 -5
- package/src/agent/live-subagents.ts +5 -0
- package/src/agent/loop-guard.ts +112 -26
- package/src/agent/plugin-tools.ts +167 -50
- package/src/agent/session-origin.ts +3 -3
- package/src/agent/subagent-drain.ts +150 -0
- package/src/agent/subagents.ts +41 -3
- package/src/agent/system-prompt.ts +29 -4
- package/src/agent/tools/channel-send.ts +1 -1
- package/src/agent/tools/spawn-subagent.ts +34 -1
- package/src/agent/tools/subagent-output.ts +7 -3
- package/src/agent/tools/wikipedia.ts +1 -1
- package/src/bundled-plugins/bun-hygiene/README.md +12 -11
- package/src/bundled-plugins/bun-hygiene/policy.ts +8 -3
- package/src/bundled-plugins/explorer/explorer.ts +2 -0
- package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +94 -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 +283 -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 +233 -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 +28 -9
- 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/github-review-claim.ts +15 -3
- package/src/channels/router.ts +85 -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/migrations/index.ts +35 -0
- package/src/migrations/secrets-v1-to-v2.ts +344 -0
- package/src/run/bundled-plugins.ts +4 -0
- package/src/run/index.ts +13 -0
- package/src/sandbox/availability.ts +12 -0
- package/src/sandbox/build.ts +12 -0
- package/src/sandbox/index.ts +1 -1
- package/src/sandbox/policy.ts +8 -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
|
//
|
|
@@ -53,6 +53,12 @@ const WARN_POSITIVE_CLOSEOUT: readonly RegExp[] = [
|
|
|
53
53
|
/\bshould be (fine|good)\b/,
|
|
54
54
|
/\blooks resolved\b/,
|
|
55
55
|
/\bseems resolved\b/,
|
|
56
|
+
// The canonical PR #672 close-out: "that addresses the concern", "addressed
|
|
57
|
+
// your feedback". On a PR the bot still blocks, this READS as a verdict and
|
|
58
|
+
// strands the block, so it escalates through the re-review guard. Demoted to
|
|
59
|
+
// ignore by the negation/future markers below ("haven't addressed", "to
|
|
60
|
+
// address").
|
|
61
|
+
/\baddress(es|ed)\b[^.!?]*\b(concern|feedback|review|comment|issue|point)/,
|
|
56
62
|
]
|
|
57
63
|
|
|
58
64
|
// Negative warn phrases re-assert a block ("not done yet") instead of closing it
|
|
@@ -65,11 +71,17 @@ const WARN: readonly RegExp[] = [...WARN_POSITIVE_CLOSEOUT, ...WARN_NEGATIVE]
|
|
|
65
71
|
// ignore. Blocking "I haven't approved" / "I'll approve" / "approved it earlier"
|
|
66
72
|
// (answering a question) is the worst false-positive class, so it is checked first.
|
|
67
73
|
const DEMOTE_TO_IGNORE: readonly RegExp[] = [
|
|
68
|
-
/\b(haven'?t|have not|did ?n'?t|did not|not yet|never)\b[^.!?]*\b(approv|request|resolv|block)/,
|
|
69
|
-
/\b(can'?t|cannot|won'?t|will not|wouldn'?t)\b[^.!?]*\b(approv|request|resolv|block)/,
|
|
70
|
-
/\bnot (approved|resolved|blocked|requesting)\b/,
|
|
74
|
+
/\b(haven'?t|have not|did ?n'?t|did not|not yet|never)\b[^.!?]*\b(approv|request|resolv|block|address)/,
|
|
75
|
+
/\b(can'?t|cannot|won'?t|will not|wouldn'?t)\b[^.!?]*\b(approv|request|resolv|block|address)/,
|
|
76
|
+
/\bnot (approved|resolved|blocked|requesting|addressed)\b/,
|
|
71
77
|
/\b(not|no longer|hardly|barely)\b[^.!?]*\b(lgtm|looks good|looks fine|seems fine|should be (fine|good)|looks resolved|seems resolved)\b/,
|
|
72
78
|
/\b(i'?ll|i will|going to|gonna|about to|planning to)\b[^.!?]*\b(approv|review|request|resolv)/,
|
|
79
|
+
// "address" demotion is restricted to explicit future/obligation forms only.
|
|
80
|
+
// A standalone `to` marker (e.g. "...to address my feedback") would match
|
|
81
|
+
// hard-claim prose like "Approved — thanks for updating the docs to address
|
|
82
|
+
// my feedback" and demote it to ignore BEFORE the BLOCK_APPROVE check, hiding
|
|
83
|
+
// a real verdict (the recovery path would then post it unguarded — PR #675).
|
|
84
|
+
/\b(i'?ll|i will|going to|gonna|about to|planning to|need(s)? to|have to|want(s)? to|trying to)\b[^.!?]*\baddress/,
|
|
73
85
|
/\b(approved|resolved|requested changes)\b[^.!?]*\b(earlier|already|yesterday|before|last (review|time)|previously)\b/,
|
|
74
86
|
/\b(pre|self|co|re|un|non|ai|admin|user|machine|auto) approved\b/,
|
|
75
87
|
]
|
package/src/channels/router.ts
CHANGED
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
StickyLedger,
|
|
33
33
|
type EngagementDecision,
|
|
34
34
|
} from './engagement'
|
|
35
|
+
import { checkFalseReceipt } from './github-false-receipt'
|
|
36
|
+
import { evaluateRereviewGuard } from './github-rereview-guard'
|
|
35
37
|
import { resetReviewTurn } from './github-review-turn-ledger'
|
|
36
38
|
import {
|
|
37
39
|
MEMBERSHIP_COLD_FETCH_TIMEOUT_MS,
|
|
@@ -659,6 +661,11 @@ export type ChannelRouter = {
|
|
|
659
661
|
removeReaction: (req: RemoveReactionRequest) => Promise<ReactionResult>
|
|
660
662
|
registerTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
|
|
661
663
|
unregisterTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
|
|
664
|
+
// Deliberately separate from registerTyping: github registers a no-op typing
|
|
665
|
+
// callback (no typing API) yet must stay typing-less, so "has a callback" is
|
|
666
|
+
// the wrong signal. autoReactOnEngage reads this to post :eyes: only as a
|
|
667
|
+
// fallback when no visible typing exists. Unset defaults to false.
|
|
668
|
+
setTypingCapability: (adapter: ChannelKey['adapter'], supported: boolean) => void
|
|
662
669
|
registerChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
|
|
663
670
|
unregisterChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
|
|
664
671
|
// Self-identity is a per-adapter singleton (one bot account per adapter),
|
|
@@ -953,6 +960,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
953
960
|
const reactionCallbacks = new Map<ChannelKey['adapter'], Set<ReactionCallback>>()
|
|
954
961
|
const removeReactionCallbacks = new Map<ChannelKey['adapter'], Set<RemoveReactionCallback>>()
|
|
955
962
|
const typingCallbacks = new Map<ChannelKey['adapter'], Set<TypingCallback>>()
|
|
963
|
+
const typingCapableAdapters = new Set<ChannelKey['adapter']>()
|
|
956
964
|
const channelNameResolvers = new Map<ChannelKey['adapter'], Set<ChannelNameResolver>>()
|
|
957
965
|
const membershipResolvers = new Map<ChannelKey['adapter'], Set<MembershipResolver>>()
|
|
958
966
|
const selfIdentityResolvers = new Map<ChannelKey['adapter'], ChannelSelfIdentityResolver>()
|
|
@@ -1513,7 +1521,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1513
1521
|
logger.error(`[channels] ${keyId}: ensureLive failed: ${describe(err)}`)
|
|
1514
1522
|
throw err
|
|
1515
1523
|
} finally {
|
|
1516
|
-
|
|
1524
|
+
// Owner-checked delete: only clear the in-flight marker if it still points
|
|
1525
|
+
// at THIS promise. A watchdog timeout can orphan a slow creation whose
|
|
1526
|
+
// `finally` runs while a later inbound has already installed its own
|
|
1527
|
+
// `creating` entry for the same key; an unconditional delete would drop
|
|
1528
|
+
// that newer entry and let a third inbound cold-start a duplicate session
|
|
1529
|
+
// (observed: 3 concurrent sessions approving the same PR).
|
|
1530
|
+
if (creating.get(keyId) === promise) creating.delete(keyId)
|
|
1517
1531
|
}
|
|
1518
1532
|
}
|
|
1519
1533
|
|
|
@@ -2447,13 +2461,18 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2447
2461
|
}
|
|
2448
2462
|
|
|
2449
2463
|
// Best-effort acknowledgment: drop an :eyes: on the triggering inbound the
|
|
2450
|
-
// moment we decide to engage
|
|
2451
|
-
//
|
|
2452
|
-
//
|
|
2453
|
-
//
|
|
2454
|
-
//
|
|
2464
|
+
// moment we decide to engage — but ONLY when the channel has no visible
|
|
2465
|
+
// "typing…" indicator. Where typing renders (slack/discord/telegram) the
|
|
2466
|
+
// heartbeat already signals "the bot is working", so the reaction would be
|
|
2467
|
+
// redundant noise; the :eyes: is the fallback ack for typing-less channels
|
|
2468
|
+
// (github, kakaotalk), replacing the old "On it" comment on GitHub.
|
|
2469
|
+
// Fire-and-forget so a reaction failure (missing permission, the adapter not
|
|
2470
|
+
// supporting reactions, a transient API error) can NEVER block engagement,
|
|
2471
|
+
// enqueueing, or the agent's actual reply. No reactionRef = nothing reactable
|
|
2472
|
+
// (synthetic inbounds, reaction-less adapters) = silent skip.
|
|
2455
2473
|
const autoReactOnEngage = (event: InboundMessage): Promise<ReactionRef | null> | null => {
|
|
2456
2474
|
if (event.reactionRef === undefined) return null
|
|
2475
|
+
if (typingCapableAdapters.has(event.adapter)) return null
|
|
2457
2476
|
const addResult = react({
|
|
2458
2477
|
adapter: event.adapter,
|
|
2459
2478
|
workspace: event.workspace,
|
|
@@ -2522,6 +2541,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2522
2541
|
typingCallbacks.get(adapter)?.delete(cb)
|
|
2523
2542
|
}
|
|
2524
2543
|
|
|
2544
|
+
const setTypingCapability = (adapter: ChannelKey['adapter'], supported: boolean): void => {
|
|
2545
|
+
if (supported) typingCapableAdapters.add(adapter)
|
|
2546
|
+
else typingCapableAdapters.delete(adapter)
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2525
2549
|
const registerChannelNameResolver = (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver): void => {
|
|
2526
2550
|
let set = channelNameResolvers.get(adapter)
|
|
2527
2551
|
if (!set) {
|
|
@@ -3103,6 +3127,25 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3103
3127
|
// the model's pre-tool commentary is the only user-facing text we have.
|
|
3104
3128
|
// Recovering it means the user gets *something* — strictly better than
|
|
3105
3129
|
// the historical silent drop.
|
|
3130
|
+
// Egress-level GitHub review guards. The false-receipt and re-review
|
|
3131
|
+
// stranding guards live inside the channel_reply / channel_send tool
|
|
3132
|
+
// handlers, but recovery surfaces trailing assistant prose through a
|
|
3133
|
+
// `source:'system'` send that never touches those handlers. A model that
|
|
3134
|
+
// ends its turn with a close-out ack ("that addresses the concern") instead
|
|
3135
|
+
// of calling a channel tool would otherwise post a verdict-shaped comment
|
|
3136
|
+
// while still holding its own CHANGES_REQUESTED — stranding the PR (PR #672).
|
|
3137
|
+
// Re-run the guards here and SUPPRESS on block: recovery cannot land the
|
|
3138
|
+
// missing formal review on the model's behalf, and posting the unguarded ack
|
|
3139
|
+
// is worse than dropping it — the next inbound re-prompts the model, which
|
|
3140
|
+
// can then land the verdict properly.
|
|
3141
|
+
const recoveryBlock = await evaluateRecoveryReviewGuards(live, assistantText)
|
|
3142
|
+
if (recoveryBlock !== null) {
|
|
3143
|
+
logger.warn(
|
|
3144
|
+
`[channels] ${live.keyId}: suppressed recovery (github review guard) reason=${JSON.stringify(recoveryBlock)} text_len=${assistantText.length}`,
|
|
3145
|
+
)
|
|
3146
|
+
return
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3106
3149
|
logger.warn(
|
|
3107
3150
|
`[channels] ${live.keyId}: recovering assistant_text_without_channel_tool source=${source} text_len=${assistantText.length}`,
|
|
3108
3151
|
)
|
|
@@ -3121,6 +3164,38 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3121
3164
|
}
|
|
3122
3165
|
}
|
|
3123
3166
|
|
|
3167
|
+
// Returns a block reason when the recovered text would be denied by a github
|
|
3168
|
+
// review guard, or null when it is safe to surface. Non-github channels and
|
|
3169
|
+
// non-PR chats short-circuit inside each guard (adapter / `pr:\d+` checks), so
|
|
3170
|
+
// this is a no-op for everything except GitHub PR sessions.
|
|
3171
|
+
const evaluateRecoveryReviewGuards = async (live: LiveSession, text: string): Promise<string | null> => {
|
|
3172
|
+
const falseReceipt = checkFalseReceipt({
|
|
3173
|
+
sessionId: live.sessionId,
|
|
3174
|
+
adapter: live.key.adapter,
|
|
3175
|
+
workspace: live.key.workspace,
|
|
3176
|
+
chat: live.key.chat,
|
|
3177
|
+
thread: live.key.thread,
|
|
3178
|
+
text,
|
|
3179
|
+
isContinue: false,
|
|
3180
|
+
resolveReviewThread: false,
|
|
3181
|
+
})
|
|
3182
|
+
if (falseReceipt.kind === 'block') return falseReceipt.reason
|
|
3183
|
+
|
|
3184
|
+
const rereview = await evaluateRereviewGuard({
|
|
3185
|
+
adapter: live.key.adapter,
|
|
3186
|
+
workspace: live.key.workspace,
|
|
3187
|
+
chat: live.key.chat,
|
|
3188
|
+
thread: live.key.thread,
|
|
3189
|
+
text,
|
|
3190
|
+
wantsResolve: false,
|
|
3191
|
+
isContinue: false,
|
|
3192
|
+
getReviewState: (req) => getReviewState(req),
|
|
3193
|
+
})
|
|
3194
|
+
if (rereview.block) return rereview.reason
|
|
3195
|
+
|
|
3196
|
+
return null
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3124
3199
|
const getConsecutiveSendCount = (target: {
|
|
3125
3200
|
adapter: ChannelKey['adapter']
|
|
3126
3201
|
workspace: string
|
|
@@ -3550,6 +3625,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3550
3625
|
removeReaction,
|
|
3551
3626
|
registerTyping,
|
|
3552
3627
|
unregisterTyping,
|
|
3628
|
+
setTypingCapability,
|
|
3553
3629
|
registerChannelNameResolver,
|
|
3554
3630
|
unregisterChannelNameResolver,
|
|
3555
3631
|
registerSelfIdentity,
|
|
@@ -3706,7 +3782,7 @@ function composeTurnPrompt(
|
|
|
3706
3782
|
// sections used for actual conversation content (`## Recent context`,
|
|
3707
3783
|
// `## Current message`). Without the fencing, models — especially
|
|
3708
3784
|
// persona-rich ones like Kimi — read the heading as a human-authored
|
|
3709
|
-
// instruction and reply to it ("
|
|
3785
|
+
// instruction and reply to it (e.g. "Understood, I'll stop here"). The
|
|
3710
3786
|
// bracketed marker plus the explicit "Do not acknowledge or reply to
|
|
3711
3787
|
// this notice" line is the trust boundary that prevents this. New
|
|
3712
3788
|
// runtime notices (rate-limit, schema-mismatch, abort signals, etc.)
|
|
@@ -3935,8 +4011,8 @@ export type QuoteAnchorTarget = {
|
|
|
3935
4011
|
// Slack/Discord/Telegram attachments). The quote anchor is a UX
|
|
3936
4012
|
// affordance pointing the human at *their words* — quoting a sticker as
|
|
3937
4013
|
// `> Alice: [KakaoTalk attachment #1: sticker name=...]`
|
|
3938
|
-
// is noise, and for mixed inbounds like
|
|
3939
|
-
// photo 1254x1254 ...]` the human only wrote
|
|
4014
|
+
// is noise, and for mixed inbounds like `<caption> [KakaoTalk message with
|
|
4015
|
+
// photo 1254x1254 ...]` the human only wrote the caption, so the placeholder
|
|
3940
4016
|
// is the wrong thing to surface. The callsite (captureQuoteCandidate)
|
|
3941
4017
|
// treats an empty residue as "no quote anchor"; mixed inbounds keep the
|
|
3942
4018
|
// human-written portion. renderQuoteAnchor later collapses whitespace
|
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 }
|