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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -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':
@@ -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.typingTimer || live.destroyed) return
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: primary.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
  }
@@ -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 { c, done, errorLine, printSlackAppManifestSetup } from './ui'
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 privateKeyInput = await text({
809
- message: 'New GitHub App private key PEM, escaped PEM, or path to .pem file',
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: await resolvePrivateKeyInput(privateKeyInput) }
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 privateKeyInput = await text({
859
- message: 'GitHub App private key PEM, escaped PEM, or path to .pem file',
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: await resolvePrivateKeyInput(privateKeyInput),
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 { c, done, errorLine, printSlackAppManifestSetup } from './ui'
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 privateKeyInput = await text({
1303
- message: 'GitHub App private key PEM, escaped PEM, or path to .pem file',
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: await resolveGithubPrivateKey(privateKeyInput),
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
+ }
@@ -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 { createSessionWithDispose, type SessionOrigin } from '@/agent'
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 { session, dispose } = await createSessionWithDispose({
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. `comments[]` accepts line-level entries; each one lands on the diff exactly like a human reviewer's inline comment:
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
- -F event=COMMENT \
44
- -f body="Overall: looks good with a few nits." \
45
- -F 'comments[][path]=src/foo.ts' \
46
- -F 'comments[][line]=42' \
47
- -F 'comments[][side]=RIGHT' \
48
- -F 'comments[][body]=nit: prefer `const` here.' \
49
- -F 'comments[][path]=src/bar.ts' \
50
- -F 'comments[][line]=10' \
51
- -F 'comments[][side]=RIGHT' \
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>`.