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