typeclaw 0.11.0 → 0.11.1
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/README.md +5 -1
- package/package.json +1 -1
- package/src/agent/multimodal/look-at.ts +8 -0
- package/src/agent/system-prompt.ts +2 -0
- package/src/channels/adapters/discord-bot-invite.ts +89 -0
- package/src/channels/adapters/kakaotalk-classify.ts +13 -1
- package/src/channels/adapters/kakaotalk.ts +2 -0
- package/src/channels/router.ts +59 -5
- package/src/cli/channel.ts +9 -20
- package/src/cli/init.ts +6 -14
- package/src/cli/prompt-pem.ts +113 -0
- package/src/cli/ui.ts +22 -0
- package/src/compose/discover.ts +5 -0
- package/src/run/index.ts +2 -0
- package/src/server/command-runner.ts +31 -2
- package/src/skills/typeclaw-channel-github/SKILL.md +14 -12
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# TypeClaw
|
|
2
2
|
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="./docs/public/typeey.png" alt="Typeey, the TypeClaw mascot — a plush bird with navy wings sitting in grass" width="240" />
|
|
5
|
+
</p>
|
|
6
|
+
|
|
3
7
|
> A TypeScript-native, Bun-powered, Docker-friendly general-purpose agent runtime.
|
|
4
8
|
|
|
5
9
|
## Why?
|
|
@@ -89,7 +93,7 @@ bun run lint
|
|
|
89
93
|
bun run format
|
|
90
94
|
```
|
|
91
95
|
|
|
92
|
-
See [AGENTS.md](./AGENTS.md) for the long-form architecture notes — stages, hostd internals, message stream, plugin contracts, and the testing philosophy. The docs site at [typeclaw.dev](https://typeclaw.dev) lives in [`docs/`](./docs/).
|
|
96
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the recommended local dev loop (`bun link` → `typeclaw init`), commit and PR conventions, and where to ask questions. See [AGENTS.md](./AGENTS.md) for the long-form architecture notes — stages, hostd internals, message stream, plugin contracts, and the testing philosophy. The docs site at [typeclaw.dev](https://typeclaw.dev) lives in [`docs/`](./docs/).
|
|
93
97
|
|
|
94
98
|
## Acknowledgments
|
|
95
99
|
|
package/package.json
CHANGED
|
@@ -80,6 +80,14 @@ export const lookAtTool = defineTool({
|
|
|
80
80
|
parentSessionId: '<look-at-tool>',
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// TODO(usage-accounting): this falls through to SessionManager.inMemory()
|
|
84
|
+
// because no sessionManager is passed, so the look_at subagent's
|
|
85
|
+
// message.usage never reaches the sessions/ JSONLs that `typeclaw usage`
|
|
86
|
+
// and the bundled `backup` plugin scan. Same root-cause class as the
|
|
87
|
+
// plugin-command/cron-handler path fixed in `runPromptForCommand`
|
|
88
|
+
// (src/server/command-runner.ts). Fixing this requires threading a
|
|
89
|
+
// SessionFactory into pi-coding-agent's tool execute() signature, which
|
|
90
|
+
// is a separate change.
|
|
83
91
|
const { session, dispose } = await createSessionWithDispose({
|
|
84
92
|
systemPromptOverride: systemPrompt,
|
|
85
93
|
origin,
|
|
@@ -70,6 +70,8 @@ The bundled \`scout\` subagent is its external counterpart — web research only
|
|
|
70
70
|
|
|
71
71
|
When the user hands you a task that will take minutes (a multi-step browser session, a long build, a complex external operation), acknowledge in plain language ("Alright, running that in the background — I'll let you know when it's done"), spawn one subagent with \`run_in_background: true\`, then KEEP TALKING. Stay available for follow-ups, related questions, parallel small tasks. When the completion reminder lands, weave the result into your next reply naturally. If the conversation has gone idle, proactively message the user with the result rather than waiting.
|
|
72
72
|
|
|
73
|
+
**Concrete threshold: ~30 seconds.** If you expect a tool call to take longer than that, delegate. While your own \`bash\` is blocked, you cannot reply, the channel typing indicator cannot heartbeat past silent stretches (it caps after a couple of minutes of no tool activity by design — see \`MAX_TYPING_HEARTBEAT_MS\`), and the user sees a frozen-looking conversation. Specifically: do NOT run \`npm install\`, \`bun install\`, \`docker build\`, \`docker compose up\`, multi-target \`curl\` probes, headed-browser scrapes, WebSocket/CDP captures, long \`pytest\`/\`npm test\` suites, or any "do N requests across hosts" loop in your own session — delegate every one of those to \`operator\`. Single fast \`bash\` calls (a \`git status\`, a \`ls\`, a one-shot \`curl\` against a known endpoint) stay in your session; that's not what this rule is targeting.
|
|
74
|
+
|
|
73
75
|
In a channel session, the completion \`<system-reminder>\` is NOT a user message — the channel origin's "you MUST call \`channel_reply\` for every user message" rule does not literally apply, but the underlying constraint does: plain-text output is invisible in a channel. Surface the result via \`channel_reply\` (or \`channel_send\`) so the user actually sees it. Failures need surfacing too: when a delegated task didn't complete, the user needs the outcome and whatever partial progress you got. Skipping the reply is legal only when the user has already seen the substantive answer — typically because you posted it via \`channel_reply\` in the same turn that spawned the subagent, and the reminder is purely confirming completion of a step the user is already tracking. In that case, prefer \`skip_response({ reason: "result confirms prior reply" })\` over the \`NO_REPLY\` text sentinel — the structured tool records why, so the operator can audit silent post-completion turns. Otherwise, post the result.
|
|
74
76
|
|
|
75
77
|
Before you run a tool chain that returns bulky intermediate output you won't need again — multiple \`webfetch\` calls, a \`websearch\` round you'll iterate on, a \`bash\` command that scrapes a site or dumps a large response, an \`agent-browser\` session, a \`claude\` (Claude Code) or \`codex\` (OpenAI Codex CLI) delegation driven through tmux, any "fetch N things and synthesize" loop — delegate it to a subagent. \`scout\` (for research) or \`operator\` (for actions with side effects) runs the noisy work in its own context window and returns a distilled summary; your session carries the *answer*, not the raw material you derived it from. This is about context economy, not latency: even a fast operation belongs in a subagent when the byproducts are large and disposable (three quick news searches across different outlets still dumps three SERPs and three article bodies into your context forever). The exception is exactly one call whose result you'll cite directly — one \`webfetch\` of a known URL, one \`websearch\` query whose top result is the answer. Two of either, or any "across multiple sources" framing, is delegation territory.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Discord bot tokens are a JWT-shaped triple `<base64(user_id)>.<base64(ts)>.<hmac>`.
|
|
2
|
+
// For bot users the user id is also the application id, which is the same
|
|
3
|
+
// value Discord's OAuth2 authorize URL takes as `client_id`. Deriving it from
|
|
4
|
+
// the token the operator just pasted lets us print a ready-to-click invite
|
|
5
|
+
// URL without prompting separately for an application id.
|
|
6
|
+
//
|
|
7
|
+
// We intentionally keep this dependency-free and side-effect-free so it can be
|
|
8
|
+
// called from the host-stage CLI (`channel add`, `init`) without touching the
|
|
9
|
+
// agent-messenger SDK or making any network requests.
|
|
10
|
+
|
|
11
|
+
// Discord permission bits the discord-bot adapter actually exercises at
|
|
12
|
+
// runtime. Keep this in sync with the REST calls in discord-bot.ts —
|
|
13
|
+
// anything the adapter does (send, react, fetch history, register slash
|
|
14
|
+
// commands, attach files) needs the matching bit here, or the invite URL
|
|
15
|
+
// will under-grant and the bot will silently 403 in production.
|
|
16
|
+
//
|
|
17
|
+
// References:
|
|
18
|
+
// https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags
|
|
19
|
+
const DISCORD_PERMISSIONS = {
|
|
20
|
+
ADD_REACTIONS: 1n << 6n,
|
|
21
|
+
VIEW_CHANNEL: 1n << 10n,
|
|
22
|
+
SEND_MESSAGES: 1n << 11n,
|
|
23
|
+
EMBED_LINKS: 1n << 14n,
|
|
24
|
+
ATTACH_FILES: 1n << 15n,
|
|
25
|
+
READ_MESSAGE_HISTORY: 1n << 16n,
|
|
26
|
+
USE_APPLICATION_COMMANDS: 1n << 31n,
|
|
27
|
+
SEND_MESSAGES_IN_THREADS: 1n << 38n,
|
|
28
|
+
} as const
|
|
29
|
+
|
|
30
|
+
// Sum of every bit the adapter uses. BigInt because SEND_MESSAGES_IN_THREADS
|
|
31
|
+
// alone exceeds 2^32, so a plain `number` would silently lose precision once
|
|
32
|
+
// Discord adds another high bit we want.
|
|
33
|
+
export const DISCORD_BOT_INVITE_PERMISSIONS = Object.values(DISCORD_PERMISSIONS).reduce((acc, bit) => acc | bit, 0n)
|
|
34
|
+
|
|
35
|
+
const DISCORD_OAUTH_AUTHORIZE = 'https://discord.com/oauth2/authorize'
|
|
36
|
+
const DISCORD_BOT_SCOPES = ['bot', 'applications.commands'] as const
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Derive the application id (== bot user id) from a Discord bot token without
|
|
40
|
+
* calling the API. Returns `null` for any token whose first segment doesn't
|
|
41
|
+
* base64-decode into a snowflake — callers should treat that as "we couldn't
|
|
42
|
+
* parse it, skip the invite URL hint" rather than as an invalid token, because
|
|
43
|
+
* Discord reserves the right to change the token format and we'd rather
|
|
44
|
+
* silently fall back than block onboarding.
|
|
45
|
+
*/
|
|
46
|
+
export function deriveAppIdFromBotToken(token: string): string | null {
|
|
47
|
+
const segments = token.split('.')
|
|
48
|
+
if (segments.length !== 3) return null
|
|
49
|
+
const head = segments[0]
|
|
50
|
+
if (head === undefined || head.length === 0) return null
|
|
51
|
+
let decoded: string
|
|
52
|
+
try {
|
|
53
|
+
decoded = Buffer.from(padBase64(head), 'base64').toString('utf-8')
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
// Discord snowflakes are 17-20 digit decimal strings. Reject anything that
|
|
58
|
+
// doesn't look like one so we don't surface garbage as a "client_id".
|
|
59
|
+
if (!/^\d{17,20}$/.test(decoded)) return null
|
|
60
|
+
return decoded
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the OAuth2 invite URL Discord renders the "Add to Server" picker for.
|
|
65
|
+
* Defaults to the `bot`+`applications.commands` scope pair and the permission
|
|
66
|
+
* bitfield the discord-bot adapter actually needs.
|
|
67
|
+
*/
|
|
68
|
+
export function buildDiscordInviteUrl(
|
|
69
|
+
appId: string,
|
|
70
|
+
opts: { permissions?: bigint; scopes?: readonly string[] } = {},
|
|
71
|
+
): string {
|
|
72
|
+
const permissions = opts.permissions ?? DISCORD_BOT_INVITE_PERMISSIONS
|
|
73
|
+
const scopes = opts.scopes ?? DISCORD_BOT_SCOPES
|
|
74
|
+
const params = new URLSearchParams({
|
|
75
|
+
client_id: appId,
|
|
76
|
+
scope: scopes.join(' '),
|
|
77
|
+
permissions: permissions.toString(),
|
|
78
|
+
})
|
|
79
|
+
return `${DISCORD_OAUTH_AUTHORIZE}?${params.toString()}`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Base64 segments inside JWT-shaped tokens omit `=` padding. Node's Buffer
|
|
83
|
+
// tolerates missing padding for the standard alphabet but not for url-safe,
|
|
84
|
+
// and we want defensive behavior either way.
|
|
85
|
+
function padBase64(input: string): string {
|
|
86
|
+
const remainder = input.length % 4
|
|
87
|
+
if (remainder === 0) return input
|
|
88
|
+
return input + '='.repeat(4 - remainder)
|
|
89
|
+
}
|
|
@@ -4,7 +4,16 @@ import { matchesAnyAlias } from '@/channels/engagement'
|
|
|
4
4
|
import type { ChannelAdapterConfig } from '@/channels/schema'
|
|
5
5
|
import type { InboundMessage } from '@/channels/types'
|
|
6
6
|
|
|
7
|
-
export type InboundDropReason = 'self_author' | 'empty_text' | 'unknown_chat' | 'pre_connect'
|
|
7
|
+
export type InboundDropReason = 'self_author' | 'empty_text' | 'unknown_chat' | 'pre_connect' | 'bot_message'
|
|
8
|
+
|
|
9
|
+
// LOCO message_type 71 is KakaoTalk's notification/feed channel — official
|
|
10
|
+
// accounts like "카카오 고객센터" and "카카오계정" (login alerts, security
|
|
11
|
+
// notices, system messages). These arrive in @kakao-group buckets because
|
|
12
|
+
// they aren't normal user chats, but they are not human conversation and
|
|
13
|
+
// the agent should never reply to them. Not enumerated in
|
|
14
|
+
// agent-messenger's `KAKAO_MESSAGE_TYPE` because that const only covers
|
|
15
|
+
// user-composable types (TEXT/PHOTO/VIDEO/AUDIO/FILE/MULTIPHOTO).
|
|
16
|
+
const KAKAO_NOTIFICATION_MESSAGE_TYPE = 71
|
|
8
17
|
|
|
9
18
|
export type InboundClassification =
|
|
10
19
|
| { kind: 'drop'; reason: InboundDropReason }
|
|
@@ -32,6 +41,9 @@ export function classifyInbound(
|
|
|
32
41
|
if (String(event.author_id) === context.selfUserId) {
|
|
33
42
|
return { kind: 'drop', reason: 'self_author' }
|
|
34
43
|
}
|
|
44
|
+
if (event.message_type === KAKAO_NOTIFICATION_MESSAGE_TYPE) {
|
|
45
|
+
return { kind: 'drop', reason: 'bot_message' }
|
|
46
|
+
}
|
|
35
47
|
|
|
36
48
|
const text = event.message ?? ''
|
|
37
49
|
if (text === '') return { kind: 'drop', reason: 'empty_text' }
|
|
@@ -671,6 +671,8 @@ function dropHint(reason: InboundDropReason): string {
|
|
|
671
671
|
switch (reason) {
|
|
672
672
|
case 'unknown_chat':
|
|
673
673
|
return ' (chat not in cache after refresh and provisional registration; check earlier resolver-refresh-failed warnings)'
|
|
674
|
+
case 'bot_message':
|
|
675
|
+
return ' (LOCO message_type=71 is KakaoTalk notification/feed; official accounts like 카카오 고객센터 / 카카오계정 / login alerts)'
|
|
674
676
|
case 'empty_text':
|
|
675
677
|
case 'pre_connect':
|
|
676
678
|
case 'self_author':
|
package/src/channels/router.ts
CHANGED
|
@@ -62,6 +62,15 @@ export const TYPING_HEARTBEAT_MS = 8000
|
|
|
62
62
|
// platform-side typing forever. Slack Assistant status in particular has a
|
|
63
63
|
// documented 2-minute timeout, so repeatedly refreshing it after that point
|
|
64
64
|
// turns a temporary status into a permanent-looking artifact.
|
|
65
|
+
//
|
|
66
|
+
// The cap is measured from `live.typingStartedAt`, which is refreshed by
|
|
67
|
+
// two signals of life (see `bumpTypingActivity`):
|
|
68
|
+
// 1. Each new `drain()` iteration (a new turn is starting).
|
|
69
|
+
// 2. Each `tool_execution_end` from the agent session (a tool just
|
|
70
|
+
// completed — the prompt is progressing, not stuck).
|
|
71
|
+
// A 2-minute bash command that emits no intermediate events still trips
|
|
72
|
+
// the cap, but a chatty agent running long tools stays under it
|
|
73
|
+
// indefinitely. The cap exists to catch *silence*, not duration.
|
|
65
74
|
export const MAX_TYPING_HEARTBEAT_MS = 2 * 60 * 1000
|
|
66
75
|
|
|
67
76
|
// Idle GC: a LiveSession whose `lastInboundAt` is older than
|
|
@@ -361,6 +370,7 @@ type LiveSession = {
|
|
|
361
370
|
membershipFetch: Promise<MembershipCount | null> | null
|
|
362
371
|
destroyed: boolean
|
|
363
372
|
unsubProviderErrors: (() => void) | null
|
|
373
|
+
unsubTypingActivity: (() => void) | null
|
|
364
374
|
}
|
|
365
375
|
|
|
366
376
|
// `event` is null for command invocations that originated outside the inbound
|
|
@@ -1000,10 +1010,12 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1000
1010
|
membershipFetch,
|
|
1001
1011
|
destroyed: false,
|
|
1002
1012
|
unsubProviderErrors: null,
|
|
1013
|
+
unsubTypingActivity: null,
|
|
1003
1014
|
}
|
|
1004
1015
|
live.unsubProviderErrors = subscribeProviderErrors(created.session, (err) => {
|
|
1005
1016
|
logger.error(`[channels] ${live.keyId}: LLM call failed: ${err.message}`)
|
|
1006
1017
|
})
|
|
1018
|
+
live.unsubTypingActivity = subscribeTypingActivity(created.session, live)
|
|
1007
1019
|
liveSessions.set(keyId, live)
|
|
1008
1020
|
|
|
1009
1021
|
if (isColdStart) {
|
|
@@ -1149,9 +1161,25 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1149
1161
|
)
|
|
1150
1162
|
}
|
|
1151
1163
|
|
|
1164
|
+
const bumpTypingActivity = (live: LiveSession): void => {
|
|
1165
|
+
if (live.typingTimer === null) return
|
|
1166
|
+
live.typingStartedAt = now()
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const subscribeTypingActivity = (session: AgentSession, live: LiveSession): (() => void) => {
|
|
1170
|
+
return session.subscribe((event) => {
|
|
1171
|
+
if (event.type !== 'tool_execution_end') return
|
|
1172
|
+
bumpTypingActivity(live)
|
|
1173
|
+
})
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1152
1176
|
const startTypingHeartbeat = (live: LiveSession): void => {
|
|
1153
1177
|
if (live.typingTimedOut || live.typingStopPromise) return
|
|
1154
|
-
if (live.
|
|
1178
|
+
if (live.destroyed) return
|
|
1179
|
+
if (live.typingTimer) {
|
|
1180
|
+
bumpTypingActivity(live)
|
|
1181
|
+
return
|
|
1182
|
+
}
|
|
1155
1183
|
live.typingStartedAt = now()
|
|
1156
1184
|
// Fire immediately so the indicator appears on the very first inbound,
|
|
1157
1185
|
// not 8 seconds later.
|
|
@@ -1163,7 +1191,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1163
1191
|
}
|
|
1164
1192
|
if (now() - live.typingStartedAt >= MAX_TYPING_HEARTBEAT_MS) {
|
|
1165
1193
|
logger.warn(
|
|
1166
|
-
`[channels] ${live.keyId}: typing indicator paused after ${MAX_TYPING_HEARTBEAT_MS}ms; prompt still in flight`,
|
|
1194
|
+
`[channels] ${live.keyId}: typing indicator paused after ${MAX_TYPING_HEARTBEAT_MS}ms with no activity; prompt still in flight`,
|
|
1167
1195
|
)
|
|
1168
1196
|
live.typingTimedOut = true
|
|
1169
1197
|
void stopTypingHeartbeat(live)
|
|
@@ -2029,6 +2057,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2029
2057
|
live.debounceTimer = null
|
|
2030
2058
|
live.unsubProviderErrors?.()
|
|
2031
2059
|
live.unsubProviderErrors = null
|
|
2060
|
+
live.unsubTypingActivity?.()
|
|
2061
|
+
live.unsubTypingActivity = null
|
|
2032
2062
|
await stopTypingHeartbeat(live)
|
|
2033
2063
|
try {
|
|
2034
2064
|
await live.session.abort()
|
|
@@ -2235,7 +2265,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2235
2265
|
}
|
|
2236
2266
|
if (now() - live.typingStartedAt >= MAX_TYPING_HEARTBEAT_MS) {
|
|
2237
2267
|
logger.warn(
|
|
2238
|
-
`[channels] ${live.keyId}: typing indicator paused after ${MAX_TYPING_HEARTBEAT_MS}ms; prompt still in flight`,
|
|
2268
|
+
`[channels] ${live.keyId}: typing indicator paused after ${MAX_TYPING_HEARTBEAT_MS}ms with no activity; prompt still in flight`,
|
|
2239
2269
|
)
|
|
2240
2270
|
live.typingTimedOut = true
|
|
2241
2271
|
await stopTypingHeartbeat(live)
|
|
@@ -2473,10 +2503,32 @@ export type QuoteAnchorCandidate = {
|
|
|
2473
2503
|
hadInterveningObserved: boolean
|
|
2474
2504
|
}
|
|
2475
2505
|
|
|
2506
|
+
// Strips `[<Adapter> message with ...]` placeholders that adapter
|
|
2507
|
+
// classifiers synthesize for non-text inbounds (KakaoTalk stickers,
|
|
2508
|
+
// Slack/Discord/Telegram attachments). The quote anchor is a UX
|
|
2509
|
+
// affordance pointing the human at *their words* — quoting a sticker as
|
|
2510
|
+
// `> Alice: [KakaoTalk message with sticker (sticker_ani) pack=... path=...]`
|
|
2511
|
+
// is noise, and for mixed inbounds like `사진 [KakaoTalk message with
|
|
2512
|
+
// photo 1254x1254 ...]` the human only wrote `사진`, so the placeholder
|
|
2513
|
+
// is the wrong thing to surface. The callsite (captureQuoteCandidate)
|
|
2514
|
+
// treats an empty residue as "no quote anchor"; mixed inbounds keep the
|
|
2515
|
+
// human-written portion. renderQuoteAnchor later collapses whitespace
|
|
2516
|
+
// so residual double-spaces from mid-string strips are harmless.
|
|
2517
|
+
const CHANNEL_MEDIA_PLACEHOLDER_RE = /\[(?:KakaoTalk|Slack|Discord|Telegram) message with [^\]]*\]/g
|
|
2518
|
+
|
|
2519
|
+
export function stripChannelMediaPlaceholders(text: string): string {
|
|
2520
|
+
return text
|
|
2521
|
+
.replace(CHANNEL_MEDIA_PLACEHOLDER_RE, '')
|
|
2522
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
2523
|
+
.trim()
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2476
2526
|
// Snapshot the primary inbound + observed-buffer state at drain time so
|
|
2477
2527
|
// the send-side decision has the data it needs without holding a
|
|
2478
2528
|
// reference to the batch arrays. Returns null when there's nothing
|
|
2479
|
-
// anchorable (empty batch, primary is a bot
|
|
2529
|
+
// anchorable (empty batch, primary is a bot, or primary is a non-text
|
|
2530
|
+
// inbound with no residual human-written text after stripping the
|
|
2531
|
+
// adapter's media placeholder).
|
|
2480
2532
|
//
|
|
2481
2533
|
// `hadInterveningObserved` counts ONLY live observations (`source ===
|
|
2482
2534
|
// 'observed'`), not prefetched scrollback. Prefetch stamps `receivedAt =
|
|
@@ -2495,8 +2547,10 @@ export function captureQuoteCandidate(
|
|
|
2495
2547
|
if (batch.length === 0) return null
|
|
2496
2548
|
const primary = batch[batch.length - 1]!
|
|
2497
2549
|
if (primary.authorIsBot) return null
|
|
2550
|
+
const cleaned = stripChannelMediaPlaceholders(primary.text)
|
|
2551
|
+
if (cleaned === '') return null
|
|
2498
2552
|
return {
|
|
2499
|
-
source: { adapter, authorId: primary.authorId, authorName: primary.authorName, text:
|
|
2553
|
+
source: { adapter, authorId: primary.authorId, authorName: primary.authorName, text: cleaned },
|
|
2500
2554
|
primaryReceivedAt: primary.receivedAt,
|
|
2501
2555
|
hadInterveningObserved: hasInterveningObserved(primary.receivedAt, observed),
|
|
2502
2556
|
}
|
package/src/cli/channel.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto'
|
|
2
|
-
import { readFile } from 'node:fs/promises'
|
|
3
2
|
|
|
4
3
|
import { cancel, confirm, intro, isCancel, log, note, password, select, spinner, text } from '@clack/prompts'
|
|
5
4
|
import { defineCommand } from 'citty'
|
|
@@ -27,7 +26,8 @@ import {
|
|
|
27
26
|
import { runKakaotalkBootstrap } from '@/init/kakaotalk-auth'
|
|
28
27
|
import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
|
|
29
28
|
|
|
30
|
-
import {
|
|
29
|
+
import { CANCEL_SYMBOL, promptPrivateKeyPem } from './prompt-pem'
|
|
30
|
+
import { c, done, errorLine, printDiscordInviteHint, printSlackAppManifestSetup } from './ui'
|
|
31
31
|
|
|
32
32
|
const CHANNEL_LABELS: Record<ChannelKind, string> = {
|
|
33
33
|
'slack-bot': 'Slack',
|
|
@@ -805,15 +805,12 @@ async function promptGithubAuthUpdate(currentType: 'pat' | 'app'): Promise<Githu
|
|
|
805
805
|
].join('\n'),
|
|
806
806
|
'Rotate the GitHub App private key',
|
|
807
807
|
)
|
|
808
|
-
const
|
|
809
|
-
|
|
810
|
-
validate: (value) => (value && value.length > 0 ? undefined : 'Private key is required'),
|
|
811
|
-
})
|
|
812
|
-
if (isCancel(privateKeyInput)) {
|
|
808
|
+
const privateKey = await promptPrivateKeyPem('New GitHub App private key PEM, escaped PEM, or path to .pem file')
|
|
809
|
+
if (privateKey === CANCEL_SYMBOL) {
|
|
813
810
|
cancel('Aborted.')
|
|
814
811
|
process.exit(0)
|
|
815
812
|
}
|
|
816
|
-
return { type: 'app', privateKey
|
|
813
|
+
return { type: 'app', privateKey }
|
|
817
814
|
}
|
|
818
815
|
|
|
819
816
|
note(
|
|
@@ -855,11 +852,8 @@ async function promptGithubAppAuth(): Promise<{
|
|
|
855
852
|
cancel('Aborted.')
|
|
856
853
|
process.exit(0)
|
|
857
854
|
}
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
validate: (value) => (value && value.length > 0 ? undefined : 'Private key is required'),
|
|
861
|
-
})
|
|
862
|
-
if (isCancel(privateKeyInput)) {
|
|
855
|
+
const privateKey = await promptPrivateKeyPem('GitHub App private key PEM, escaped PEM, or path to .pem file')
|
|
856
|
+
if (privateKey === CANCEL_SYMBOL) {
|
|
863
857
|
cancel('Aborted.')
|
|
864
858
|
process.exit(0)
|
|
865
859
|
}
|
|
@@ -876,17 +870,11 @@ async function promptGithubAppAuth(): Promise<{
|
|
|
876
870
|
return {
|
|
877
871
|
type: 'app',
|
|
878
872
|
appId: Number(appId),
|
|
879
|
-
privateKey
|
|
873
|
+
privateKey,
|
|
880
874
|
...(parsedInstallationId !== undefined ? { installationId: parsedInstallationId } : {}),
|
|
881
875
|
}
|
|
882
876
|
}
|
|
883
877
|
|
|
884
|
-
async function resolvePrivateKeyInput(input: string): Promise<string> {
|
|
885
|
-
const normalized = input.replace(/\\n/g, '\n')
|
|
886
|
-
if (normalized.includes('-----BEGIN') && normalized.includes('PRIVATE KEY-----')) return normalized
|
|
887
|
-
return await readFile(input, 'utf8')
|
|
888
|
-
}
|
|
889
|
-
|
|
890
878
|
function parseRepos(input: string): string[] {
|
|
891
879
|
return input
|
|
892
880
|
.split(',')
|
|
@@ -927,6 +915,7 @@ async function promptDiscordToken(): Promise<string> {
|
|
|
927
915
|
cancel('Aborted.')
|
|
928
916
|
process.exit(0)
|
|
929
917
|
}
|
|
918
|
+
printDiscordInviteHint(token)
|
|
930
919
|
return token
|
|
931
920
|
}
|
|
932
921
|
|
package/src/cli/init.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto'
|
|
2
|
-
import { readFile } from 'node:fs/promises'
|
|
3
2
|
|
|
4
3
|
import { cancel, confirm, intro, isCancel, log, note, password, select, spinner, text } from '@clack/prompts'
|
|
5
4
|
import { defineCommand } from 'citty'
|
|
@@ -36,7 +35,8 @@ import { makeOAuthLoginRunner, type OAuthLoginResult } from '@/init/oauth-login'
|
|
|
36
35
|
import { API_KEY_DASHBOARD_URL, validateApiKey, type KeyValidationResult } from '@/init/validate-api-key'
|
|
37
36
|
|
|
38
37
|
import { buildOAuthCallbacks } from './oauth-callbacks'
|
|
39
|
-
import {
|
|
38
|
+
import { CANCEL_SYMBOL, promptPrivateKeyPem } from './prompt-pem'
|
|
39
|
+
import { c, done, errorLine, printDiscordInviteHint, printSlackAppManifestSetup } from './ui'
|
|
40
40
|
|
|
41
41
|
// ESC and Ctrl+C both produce clack's cancel symbol (the keypress layer
|
|
42
42
|
// aliases both to the same "cancel" action — there's no way to tell them
|
|
@@ -1066,6 +1066,7 @@ async function runDiscordFlow(): Promise<StepResult<CollectedInputs['channelSecr
|
|
|
1066
1066
|
validate: (v) => (v && v.length > 0 ? undefined : 'Token is required'),
|
|
1067
1067
|
})
|
|
1068
1068
|
if (isCancel(token)) return back()
|
|
1069
|
+
printDiscordInviteHint(token)
|
|
1069
1070
|
return value({ discordBotToken: token })
|
|
1070
1071
|
}
|
|
1071
1072
|
|
|
@@ -1299,11 +1300,8 @@ async function promptGithubAppAuth(): Promise<{
|
|
|
1299
1300
|
validate: (v) => validatePositiveInteger(v ?? '', 'App ID is required'),
|
|
1300
1301
|
})
|
|
1301
1302
|
if (isCancel(appId)) return null
|
|
1302
|
-
const
|
|
1303
|
-
|
|
1304
|
-
validate: (v) => (v && v.length > 0 ? undefined : 'Private key is required'),
|
|
1305
|
-
})
|
|
1306
|
-
if (isCancel(privateKeyInput)) return null
|
|
1303
|
+
const privateKey = await promptPrivateKeyPem('GitHub App private key PEM, escaped PEM, or path to .pem file')
|
|
1304
|
+
if (privateKey === CANCEL_SYMBOL) return null
|
|
1307
1305
|
const installationId = await text({
|
|
1308
1306
|
message: 'Installation ID (optional; leave blank to auto-discover)',
|
|
1309
1307
|
validate: (v) =>
|
|
@@ -1314,17 +1312,11 @@ async function promptGithubAppAuth(): Promise<{
|
|
|
1314
1312
|
return {
|
|
1315
1313
|
type: 'app',
|
|
1316
1314
|
appId: Number(appId),
|
|
1317
|
-
privateKey
|
|
1315
|
+
privateKey,
|
|
1318
1316
|
...(parsedInstallationId !== undefined ? { installationId: parsedInstallationId } : {}),
|
|
1319
1317
|
}
|
|
1320
1318
|
}
|
|
1321
1319
|
|
|
1322
|
-
async function resolveGithubPrivateKey(input: string): Promise<string> {
|
|
1323
|
-
const normalized = input.replace(/\\n/g, '\n')
|
|
1324
|
-
if (normalized.includes('-----BEGIN') && normalized.includes('PRIVATE KEY-----')) return normalized
|
|
1325
|
-
return await readFile(input, 'utf8')
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
1320
|
function parseGithubRepos(input: string): string[] {
|
|
1329
1321
|
return input
|
|
1330
1322
|
.split(',')
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { createInterface, type Interface } from 'node:readline'
|
|
3
|
+
|
|
4
|
+
import { log } from '@clack/prompts'
|
|
5
|
+
|
|
6
|
+
const BEGIN_MARKER = '-----BEGIN'
|
|
7
|
+
const END_MARKER_RE = /^-----END [A-Z0-9 ]*PRIVATE KEY-----\s*$/
|
|
8
|
+
const END_MARKER_INLINE_RE = /-----END [A-Z0-9 ]*PRIVATE KEY-----/
|
|
9
|
+
|
|
10
|
+
export const CANCEL_SYMBOL = Symbol('cancel')
|
|
11
|
+
|
|
12
|
+
export type ReadLineFn = () => Promise<string | typeof CANCEL_SYMBOL>
|
|
13
|
+
|
|
14
|
+
export async function promptPrivateKeyPem(message: string): Promise<string | typeof CANCEL_SYMBOL> {
|
|
15
|
+
log.step(message)
|
|
16
|
+
log.message('Paste the PEM (including BEGIN/END lines), a path to a .pem file, or an escaped PEM.')
|
|
17
|
+
|
|
18
|
+
const reader = createStdinLineReader()
|
|
19
|
+
try {
|
|
20
|
+
const raw = await readPrivateKeyFromLines(reader.next)
|
|
21
|
+
if (raw === CANCEL_SYMBOL) return CANCEL_SYMBOL
|
|
22
|
+
return await resolvePrivateKeyInput(raw)
|
|
23
|
+
} finally {
|
|
24
|
+
reader.close()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read a PEM block (or single-line value) using `readLine`.
|
|
30
|
+
*
|
|
31
|
+
* A line starting with `-----BEGIN` switches into block mode, accumulating
|
|
32
|
+
* until a line matches `-----END ... PRIVATE KEY-----`. Otherwise the first
|
|
33
|
+
* non-empty line is returned verbatim (path or escaped PEM). Leading blank
|
|
34
|
+
* lines are skipped so a stray Enter does not abort the prompt.
|
|
35
|
+
*/
|
|
36
|
+
export async function readPrivateKeyFromLines(readLine: ReadLineFn): Promise<string | typeof CANCEL_SYMBOL> {
|
|
37
|
+
let first: string
|
|
38
|
+
while (true) {
|
|
39
|
+
const line = await readLine()
|
|
40
|
+
if (line === CANCEL_SYMBOL) return CANCEL_SYMBOL
|
|
41
|
+
if (line.trim().length > 0) {
|
|
42
|
+
first = line
|
|
43
|
+
break
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!first.trimStart().startsWith(BEGIN_MARKER)) return first.trim()
|
|
48
|
+
|
|
49
|
+
// Escaped-PEM pasted as one line (contains both BEGIN and END markers and
|
|
50
|
+
// no real newlines) bypasses block mode entirely.
|
|
51
|
+
if (END_MARKER_INLINE_RE.test(first)) return first.trim()
|
|
52
|
+
|
|
53
|
+
const lines: string[] = [first.trimEnd()]
|
|
54
|
+
while (true) {
|
|
55
|
+
const line = await readLine()
|
|
56
|
+
if (line === CANCEL_SYMBOL) return CANCEL_SYMBOL
|
|
57
|
+
const trimmed = line.trimEnd()
|
|
58
|
+
lines.push(trimmed)
|
|
59
|
+
if (END_MARKER_RE.test(trimmed)) break
|
|
60
|
+
}
|
|
61
|
+
return `${lines.join('\n')}\n`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function resolvePrivateKeyInput(input: string): Promise<string> {
|
|
65
|
+
const unescaped = input.includes('\\n') && !input.includes('\n') ? input.replace(/\\n/g, '\n') : input
|
|
66
|
+
if (unescaped.includes('-----BEGIN') && unescaped.includes('PRIVATE KEY-----')) return unescaped
|
|
67
|
+
return await readFile(input, 'utf8')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type StdinLineReader = {
|
|
71
|
+
next: ReadLineFn
|
|
72
|
+
close: () => void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function createStdinLineReader(): StdinLineReader {
|
|
76
|
+
return createReadlineLineReader(process.stdin)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createReadlineLineReader(input: NodeJS.ReadableStream): StdinLineReader {
|
|
80
|
+
const rl: Interface = createInterface({ input, terminal: false })
|
|
81
|
+
const queue: string[] = []
|
|
82
|
+
const waiters: ((value: string | typeof CANCEL_SYMBOL) => void)[] = []
|
|
83
|
+
let closed = false
|
|
84
|
+
|
|
85
|
+
rl.on('line', (line) => {
|
|
86
|
+
const waiter = waiters.shift()
|
|
87
|
+
if (waiter) waiter(line)
|
|
88
|
+
else queue.push(line)
|
|
89
|
+
})
|
|
90
|
+
rl.on('close', () => {
|
|
91
|
+
closed = true
|
|
92
|
+
for (const w of waiters.splice(0)) w(CANCEL_SYMBOL)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const next: ReadLineFn = () =>
|
|
96
|
+
new Promise((resolve) => {
|
|
97
|
+
const queued = queue.shift()
|
|
98
|
+
if (queued !== undefined) {
|
|
99
|
+
resolve(queued)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
if (closed) {
|
|
103
|
+
resolve(CANCEL_SYMBOL)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
waiters.push(resolve)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
next,
|
|
111
|
+
close: () => rl.close(),
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/cli/ui.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { styleText } from 'node:util'
|
|
|
2
2
|
|
|
3
3
|
import { cancel, intro, isCancel, log, note, outro, spinner as clackSpinner } from '@clack/prompts'
|
|
4
4
|
|
|
5
|
+
import { buildDiscordInviteUrl, deriveAppIdFromBotToken } from '@/channels/adapters/discord-bot-invite'
|
|
5
6
|
import { type AutoUpgradeOutcome, describeAutoUpgrade } from '@/init/auto-upgrade'
|
|
6
7
|
|
|
7
8
|
export { cancel, intro, isCancel, log, note, outro }
|
|
@@ -243,3 +244,24 @@ export function printSlackAppManifestSetup(output: NodeJS.WritableStream = proce
|
|
|
243
244
|
'Finish Slack setup',
|
|
244
245
|
)
|
|
245
246
|
}
|
|
247
|
+
|
|
248
|
+
// Discord's portal hands out a bot token but no invite URL — operators have to
|
|
249
|
+
// hunt down Application ID → OAuth2 Generator → tick scopes → tick permissions
|
|
250
|
+
// → copy. We short-circuit all of that: the application id is encoded in the
|
|
251
|
+
// token's first base64 segment, so we can hand back a click-ready URL with
|
|
252
|
+
// the exact permission bitfield the adapter uses. No-ops when the token isn't
|
|
253
|
+
// parseable as a Discord bot token so we never block onboarding on best-effort
|
|
254
|
+
// guidance.
|
|
255
|
+
export function printDiscordInviteHint(token: string): void {
|
|
256
|
+
const appId = deriveAppIdFromBotToken(token)
|
|
257
|
+
if (appId === null) return
|
|
258
|
+
note(
|
|
259
|
+
[
|
|
260
|
+
buildDiscordInviteUrl(appId),
|
|
261
|
+
'',
|
|
262
|
+
'Open it, pick a server, click Authorize.',
|
|
263
|
+
"The bot won't receive messages until it's in at least one server.",
|
|
264
|
+
].join('\n'),
|
|
265
|
+
'Invite the bot to a server',
|
|
266
|
+
)
|
|
267
|
+
}
|
package/src/compose/discover.ts
CHANGED
|
@@ -15,6 +15,10 @@ export type AgentEntry = {
|
|
|
15
15
|
// node_modules-style hidden dirs are not skipped because they don't match the
|
|
16
16
|
// dot-prefix rule, but they also won't pass the typeclaw.json filter).
|
|
17
17
|
//
|
|
18
|
+
// Underscore-prefixed names are also skipped so operators can park a disabled
|
|
19
|
+
// or in-progress agent next to live ones (e.g. `_archived-coder/`) without
|
|
20
|
+
// compose touching it.
|
|
21
|
+
//
|
|
18
22
|
// Returns an empty array when rootCwd doesn't exist or is empty — discovery is
|
|
19
23
|
// not the place to fail; the caller decides what to do with zero agents.
|
|
20
24
|
//
|
|
@@ -33,6 +37,7 @@ export function discoverAgents(rootCwd: string): AgentEntry[] {
|
|
|
33
37
|
for (const entry of entries) {
|
|
34
38
|
if (!entry.isDir) continue
|
|
35
39
|
if (entry.name.startsWith('.')) continue
|
|
40
|
+
if (entry.name.startsWith('_')) continue
|
|
36
41
|
const cwd = join(root, entry.name)
|
|
37
42
|
if (!isInitialized(cwd)) continue
|
|
38
43
|
agents.push({ name: entry.name, cwd, containerName: containerNameFromCwd(cwd) })
|
package/src/run/index.ts
CHANGED
|
@@ -338,6 +338,7 @@ export async function startAgent({
|
|
|
338
338
|
signal: abortController.signal,
|
|
339
339
|
runtimeVersion: runtimeVersionOpt.runtimeVersion,
|
|
340
340
|
containerName: containerNameOpt.containerName,
|
|
341
|
+
sessionFactory,
|
|
341
342
|
}),
|
|
342
343
|
subagent: (subName: string, payload?: unknown) =>
|
|
343
344
|
dispatchSpawnSubagent(subName, payload, {
|
|
@@ -551,6 +552,7 @@ export async function startAgent({
|
|
|
551
552
|
runtimeVersion: CLI_VERSION,
|
|
552
553
|
containerName,
|
|
553
554
|
outbound,
|
|
555
|
+
sessionFactory,
|
|
554
556
|
})
|
|
555
557
|
|
|
556
558
|
const server = createServer({
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createSessionWithDispose,
|
|
3
|
+
type CreateSessionOptions,
|
|
4
|
+
type CreateSessionResult,
|
|
5
|
+
type SessionOrigin,
|
|
6
|
+
} from '@/agent'
|
|
2
7
|
import type { PermissionService } from '@/permissions'
|
|
3
8
|
import type {
|
|
4
9
|
CommandExecResult,
|
|
@@ -11,6 +16,7 @@ import type {
|
|
|
11
16
|
SpawnSubagentOptions,
|
|
12
17
|
} from '@/plugin'
|
|
13
18
|
import type { PluginRuntime } from '@/run/plugin-runtime'
|
|
19
|
+
import type { SessionFactory } from '@/sessions'
|
|
14
20
|
|
|
15
21
|
export type CommandSpawnSubagent = (name: string, payload?: unknown, options?: SpawnSubagentOptions) => Promise<void>
|
|
16
22
|
|
|
@@ -29,6 +35,14 @@ export type CommandRunnerOptions = {
|
|
|
29
35
|
runtimeVersion: string | undefined
|
|
30
36
|
containerName: string | undefined
|
|
31
37
|
outbound: CommandOutbound
|
|
38
|
+
// Hands a persisted SessionManager to every prompt session spawned from a
|
|
39
|
+
// plugin command's `ctx.prompt`. Required so the session writes its JSONL
|
|
40
|
+
// (and therefore its `message.usage`) under sessions/, which is what
|
|
41
|
+
// `typeclaw usage` and the `bundled-plugins/backup` plugin scan. Without
|
|
42
|
+
// this every plugin-command LLM call would fall through to
|
|
43
|
+
// `SessionManager.inMemory()` and never persist usage — see
|
|
44
|
+
// `runPromptForCommand` below.
|
|
45
|
+
sessionFactory: SessionFactory
|
|
32
46
|
}
|
|
33
47
|
|
|
34
48
|
type CommandHandle = {
|
|
@@ -166,6 +180,7 @@ export function createCommandRunner(opts: CommandRunnerOptions): CommandRunner {
|
|
|
166
180
|
containerName: opts.containerName,
|
|
167
181
|
permissions: opts.permissions,
|
|
168
182
|
signal: abortController.signal,
|
|
183
|
+
sessionFactory: opts.sessionFactory,
|
|
169
184
|
}),
|
|
170
185
|
subagent: (subName, payload) =>
|
|
171
186
|
opts.spawnSubagent(subName, payload, {
|
|
@@ -331,6 +346,8 @@ function writeLine(stream: WritableStream<Uint8Array>, line: string): void {
|
|
|
331
346
|
void writer.write(new TextEncoder().encode(`${line}\n`)).then(() => writer.releaseLock())
|
|
332
347
|
}
|
|
333
348
|
|
|
349
|
+
export type CreateSessionForCommand = (options: CreateSessionOptions) => Promise<CreateSessionResult>
|
|
350
|
+
|
|
334
351
|
export async function runPromptForCommand(args: {
|
|
335
352
|
text: string
|
|
336
353
|
origin: SessionOrigin
|
|
@@ -340,6 +357,16 @@ export async function runPromptForCommand(args: {
|
|
|
340
357
|
containerName: string | undefined
|
|
341
358
|
permissions: PermissionService
|
|
342
359
|
signal: AbortSignal
|
|
360
|
+
// Persisted-session source. Each call gets a fresh SessionManager so the
|
|
361
|
+
// resulting JSONL is its own file under sessions/ — the same shape the
|
|
362
|
+
// cron `prompt` path uses in src/run/index.ts. Passing in-memory here
|
|
363
|
+
// regresses `typeclaw usage` (see CommandRunnerOptions.sessionFactory).
|
|
364
|
+
sessionFactory: SessionFactory
|
|
365
|
+
// Test seam for the agent-session boundary. Production passes the real
|
|
366
|
+
// `createSessionWithDispose`; tests inject a fake to verify wiring
|
|
367
|
+
// (specifically: the sessionManager handed off must be persisted, not
|
|
368
|
+
// in-memory) without booting the full session stack.
|
|
369
|
+
_createSession?: CreateSessionForCommand
|
|
343
370
|
}): Promise<string> {
|
|
344
371
|
// Mirrors src/agent/multimodal/look-at.ts: spawn a session, prompt, capture
|
|
345
372
|
// the final assistant text, dispose. Unlike look-at we want the FULL agent
|
|
@@ -349,9 +376,11 @@ export async function runPromptForCommand(args: {
|
|
|
349
376
|
// loader (no `systemPromptOverride`).
|
|
350
377
|
const snapshot = args.runtime.get()
|
|
351
378
|
const sessionId = resolveSessionIdForOrigin(args.origin)
|
|
352
|
-
const
|
|
379
|
+
const create = args._createSession ?? createSessionWithDispose
|
|
380
|
+
const { session, dispose } = await create({
|
|
353
381
|
origin: args.origin,
|
|
354
382
|
permissions: args.permissions,
|
|
383
|
+
sessionManager: args.sessionFactory.createPersisted(),
|
|
355
384
|
plugins: {
|
|
356
385
|
registry: snapshot.registry,
|
|
357
386
|
hooks: snapshot.hooks,
|
|
@@ -36,27 +36,29 @@ When an incoming message says **"requested your review on PR #N"** (or "requeste
|
|
|
36
36
|
gh pr view <N> --repo owner/repo --json title,body,baseRefName,headRefOid,files
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
2. **Submit a multi-comment review** in one API call
|
|
39
|
+
2. **Submit a multi-comment review** in one API call by piping a JSON payload to `gh api --input -`. `comments[]` accepts line-level entries; each one lands on the diff exactly like a human reviewer's inline comment:
|
|
40
40
|
|
|
41
41
|
```sh
|
|
42
|
-
gh api -X POST /repos/owner/repo/pulls/<N>/reviews
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
-F 'comments[][body]=Consider extracting this branch into a helper.'
|
|
42
|
+
cat <<'JSON' | gh api -X POST /repos/owner/repo/pulls/<N>/reviews --input -
|
|
43
|
+
{
|
|
44
|
+
"event": "COMMENT",
|
|
45
|
+
"body": "Overall: looks good with a few nits.",
|
|
46
|
+
"comments": [
|
|
47
|
+
{ "path": "src/foo.ts", "line": 42, "side": "RIGHT", "body": "nit: prefer `const` here." },
|
|
48
|
+
{ "path": "src/bar.ts", "line": 10, "side": "RIGHT", "body": "Consider extracting this branch into a helper." }
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
JSON
|
|
53
52
|
```
|
|
54
53
|
|
|
54
|
+
**Always use `--input -` with a quoted heredoc (`<<'JSON'`) for review bodies.** Do **not** use `-f body=...` or `-F 'comments[][body]=...'`: those go through shell argument parsing, so backticks (\`) trigger command substitution and have to be backslash-escaped, which leaks the literal `\` into the rendered comment. The quoted heredoc passes the JSON through untouched — backticks, newlines, and `${...}` all survive verbatim. The same applies to any other `gh api` POST whose body contains backticks, embedded newlines, or shell metacharacters.
|
|
55
|
+
|
|
55
56
|
3. **Then** post a one-line summary with `channel_reply` so the conversation has a human-readable trace pointing at the review.
|
|
56
57
|
|
|
57
58
|
### Rules
|
|
58
59
|
|
|
59
60
|
- Use `event=COMMENT` by default. Use `APPROVE` only when you have high confidence the PR is ready to merge. Use `REQUEST_CHANGES` only when the PR has clear blockers — not for nits.
|
|
61
|
+
- **Only post comments that the author needs to act on.** Do not post praise ("looks good", "nice refactor", "great work"), affirmations of correct code, or restatements of what a line does. If every comment in your review is positive, post a top-level summary via `channel_reply` instead of a review — or skip commenting and just `APPROVE`. Inline comments are for changes, questions, and blockers, not validation.
|
|
60
62
|
- `line` is a line number **in the file**, not a position in the diff. `side: RIGHT` is the new revision (default for additions); `side: LEFT` is the old revision (use for comments on removed lines).
|
|
61
63
|
- For multi-line comments, also set `start_line` and `start_side` (same semantics).
|
|
62
64
|
- If you need to read whole files at the PR's head SHA, use `gh api /repos/owner/repo/contents/<path>?ref=<headRefOid>`.
|