typeclaw 0.34.1 → 0.35.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.
Files changed (39) hide show
  1. package/package.json +3 -1
  2. package/src/agent/plugin-tools.ts +71 -13
  3. package/src/agent/provider-error.ts +10 -0
  4. package/src/agent/session-origin.ts +26 -0
  5. package/src/agent/tools/channel-disengage.ts +13 -9
  6. package/src/bundled-plugins/github-cli-auth/gh-command.ts +124 -6
  7. package/src/bundled-plugins/github-cli-auth/git-command.ts +172 -26
  8. package/src/bundled-plugins/github-cli-auth/index.ts +46 -7
  9. package/src/bundled-plugins/github-cli-auth/token-class.ts +13 -0
  10. package/src/bundled-plugins/security/policies/prompt-injection.ts +33 -2
  11. package/src/channels/adapters/github/inbound.ts +41 -3
  12. package/src/channels/adapters/slack-bot.ts +17 -9
  13. package/src/channels/continuation-willingness.ts +331 -0
  14. package/src/channels/github-review-claim.ts +105 -0
  15. package/src/channels/github-token-bridge.ts +7 -0
  16. package/src/channels/router.ts +103 -24
  17. package/src/cli/channel.ts +102 -11
  18. package/src/cli/qr.ts +130 -0
  19. package/src/config/config.ts +98 -2
  20. package/src/container/start.ts +12 -0
  21. package/src/init/dockerfile.ts +64 -0
  22. package/src/init/line-auth.ts +8 -3
  23. package/src/inspect/live.ts +128 -13
  24. package/src/plugin/context.ts +5 -1
  25. package/src/plugin/manager.ts +2 -0
  26. package/src/plugin/types.ts +1 -0
  27. package/src/run/index.ts +1 -0
  28. package/src/sandbox/availability.ts +87 -19
  29. package/src/sandbox/build.ts +27 -0
  30. package/src/sandbox/index.ts +10 -0
  31. package/src/sandbox/package-install.ts +23 -0
  32. package/src/sandbox/policy.ts +31 -0
  33. package/src/sandbox/symlinks.ts +34 -0
  34. package/src/sandbox/writable-zones.ts +164 -4
  35. package/src/server/index.ts +5 -1
  36. package/src/shared/protocol.ts +22 -11
  37. package/src/skills/typeclaw-channel-github/SKILL.md +4 -2
  38. package/src/skills/typeclaw-github-contributing/SKILL.md +124 -0
  39. package/typeclaw.schema.json +32 -1
@@ -24,6 +24,7 @@ import { extractClaimCode } from '@/role-claim'
24
24
  import type { Stream } from '@/stream'
25
25
 
26
26
  import { formatChannelCommandHelp } from './commands'
27
+ import { detectContinuationWillingness } from './continuation-willingness'
27
28
  import {
28
29
  countEffectiveHumans,
29
30
  decideEngagement,
@@ -239,6 +240,30 @@ export const EMPTY_TURN_RETRY_NUDGE = [
239
240
  // drop so the human is never left staring at dead air after a degenerate turn.
240
241
  export const EMPTY_TURN_FALLBACK_TEXT =
241
242
  "⚠️ I got stuck putting together a reply and couldn't finish. Could you rephrase or try again?"
243
+ // At most one continuation nudge per logical turn. Stricter than the empty-turn
244
+ // retry budget (2) because the turn ALREADY delivered a user-facing reply — this
245
+ // is a one-shot correction offer, not recovery from no output.
246
+ export const MAX_WILLINGNESS_NUDGES = 1
247
+ // Injected when a reply that ended the turn (terminal-reply abort) promised to
248
+ // keep working but omitted `continue: true`. Reminder-only, SYSTEM MESSAGE
249
+ // framing so persona-rich models do not reply to the notice itself.
250
+ export const WILLINGNESS_NUDGE = [
251
+ '---',
252
+ '**[SYSTEM MESSAGE — not from a human]**',
253
+ '',
254
+ 'Your last reply said you would keep working this turn, but it ended right',
255
+ 'after sending — a successful channel reply ends the turn unless you set',
256
+ '`continue: true` on it. This is an automated signal from the channel router,',
257
+ 'not a message from anyone in the chat. **Do not acknowledge or reply to this',
258
+ 'notice itself.**',
259
+ '',
260
+ 'If you still have work to do (fetch data, run a tool, spawn a subagent, then',
261
+ 'reply with the result), do it now — and on the status reply that precedes more',
262
+ 'work this turn, set `continue: true`. If there is nothing left to do, reply',
263
+ 'with `NO_REPLY`.',
264
+ '',
265
+ '---',
266
+ ].join('\n')
242
267
  // Rolling window for outbound send-rate telemetry. 5s matches Discord's
243
268
  // rate-limit shape (5 msg / 5 s / channel) and comfortably covers Slack's
244
269
  // 1 msg/s sustained. The window is observational; exceeding the burst
@@ -565,6 +590,18 @@ type LiveSession = {
565
590
  // increments it before injecting EMPTY_TURN_RETRY_NUDGE and reads it to decide
566
591
  // retry-vs-fallback. See the candidate===null branch.
567
592
  emptyTurnRetries: number
593
+ // Count of continuation nudges spent on the CURRENT logical turn, bounded by
594
+ // MAX_WILLINGNESS_NUDGES. Reset alongside `emptyTurnRetries` only when a real
595
+ // user batch starts (batch.length > 0), NOT on the reminder-only iteration the
596
+ // nudge itself queues — same anti-reloop discipline as the empty-turn budget.
597
+ willingnessNudges: number
598
+ // Stashed by `installChannelReplyTerminalHook` just before it aborts the turn
599
+ // after a successful `channel_reply` that omitted `continue: true`. Read once
600
+ // by `validateChannelTurn` to decide the continuation nudge. `turnSeq`-stamped
601
+ // (like `skippedTurn`/`skipLockedSendTurn`) so a stale record from an earlier
602
+ // turn can never trigger a nudge on a later one. `null` when no such reply
603
+ // ended this turn.
604
+ lastTerminalReplyAbort: { turnSeq: number; text: string } | null
568
605
  // One-shot output-token budget for the NEXT `session.prompt()` only.
569
606
  // `installChannelOutputCap` reads and clears it per stream call, so it
570
607
  // overrides the default backstop for exactly one re-prompt. Set by the
@@ -1491,6 +1528,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1491
1528
  inFlightToolSends: new Map(),
1492
1529
  policyDeniedToolSendsThisTurn: new Map(),
1493
1530
  emptyTurnRetries: 0,
1531
+ willingnessNudges: 0,
1532
+ lastTerminalReplyAbort: null,
1494
1533
  nextPromptMaxTokens: undefined,
1495
1534
  skippedTurn: null,
1496
1535
  skipLockedSendTurn: null,
@@ -1746,6 +1785,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1746
1785
  const keepTurnAlive = details?.continue === true
1747
1786
  if (succeeded && !keepTurnAlive && agent.signal?.aborted !== true) {
1748
1787
  logger.info(`[channels] ${live.keyId} terminal_after_channel_reply`)
1788
+ const replyText = (context.toolCall.arguments as { text?: unknown } | undefined)?.text
1789
+ live.lastTerminalReplyAbort = typeof replyText === 'string' ? { turnSeq: live.turnSeq, text: replyText } : null
1749
1790
  agent.abort()
1750
1791
  }
1751
1792
  return result
@@ -2029,6 +2070,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2029
2070
  // iterations the retry itself queues do not refill the budget and loop
2030
2071
  // forever (and the raised cap stays scoped to the turn that set it).
2031
2072
  live.emptyTurnRetries = 0
2073
+ live.willingnessNudges = 0
2032
2074
  live.nextPromptMaxTokens = undefined
2033
2075
  } else if (live.lastTurnAuthorId !== null) {
2034
2076
  live.currentTurnEngageReactions = []
@@ -3134,6 +3176,26 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3134
3176
  return { ok: true }
3135
3177
  }
3136
3178
 
3179
+ // The turn ended via the terminal-reply abort. If that reply promised to keep
3180
+ // working but omitted `continue: true`, queue ONE reminder-only re-prompt so
3181
+ // the model gets a second chance to actually do it. The abort already fired
3182
+ // (safe default preserved); this only adds an optional nudge. Bounded by
3183
+ // MAX_WILLINGNESS_NUDGES and gated on `promptQueue` being empty so a real
3184
+ // inbound that coalesced into this turn is never answered with a stale nudge.
3185
+ const maybeNudgeContinuationWillingness = (live: LiveSession): void => {
3186
+ const record = live.lastTerminalReplyAbort
3187
+ live.lastTerminalReplyAbort = null
3188
+ if (record === null || record.turnSeq !== live.turnSeq) return
3189
+ if (live.willingnessNudges >= MAX_WILLINGNESS_NUDGES) return
3190
+ if (live.promptQueue.length > 0) return
3191
+ if (!detectContinuationWillingness(record.text)) return
3192
+ live.willingnessNudges++
3193
+ logger.info(
3194
+ `[channels] ${live.keyId} willingness_nudge attempt=${live.willingnessNudges}/${MAX_WILLINGNESS_NUDGES}`,
3195
+ )
3196
+ live.pendingSystemReminders.push(WILLINGNESS_NUDGE)
3197
+ }
3198
+
3137
3199
  const validateChannelTurn = async (live: LiveSession, successfulSendsBeforePrompt: number): Promise<void> => {
3138
3200
  // `skip_response` short-circuit. Honoring it bypasses recovery entirely.
3139
3201
  // Stale-flag protection: only honor when stamped on the just-completed
@@ -3159,7 +3221,10 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3159
3221
  live.skippedTurn = null
3160
3222
  logger.info(`[channels] ${live.keyId} skip_contested_by_send recovering reply`)
3161
3223
  }
3162
- if (live.successfulChannelSends > successfulSendsBeforePrompt) return
3224
+ if (live.successfulChannelSends > successfulSendsBeforePrompt) {
3225
+ maybeNudgeContinuationWillingness(live)
3226
+ return
3227
+ }
3163
3228
 
3164
3229
  const postEmptyTurnFallback = async (cause: string): Promise<void> => {
3165
3230
  logger.warn(`[channels] ${live.keyId} empty_turn_fallback cause=${cause}`)
@@ -3204,6 +3269,23 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3204
3269
  // The legitimate empty-state case (a TUI-only check before any user
3205
3270
  // prompt, no inbound this turn) is excluded: no batch means no real turn
3206
3271
  // to retry or apologize for — keep the historical silent bail there.
3272
+ const leafStopReason = assistantLeafStopReason(live.session)
3273
+
3274
+ // A `stopReason: 'error'` leaf is an upstream provider failure (401, billing,
3275
+ // malformed response, etc.), NOT a reasoning-loop or budget exhaustion. It is
3276
+ // already captured by subscribeProviderErrors into `pendingProviderError`
3277
+ // (fired synchronously on `message_end` before `prompt()` resolves), and
3278
+ // `maybePostDeferredProviderError` in drain()'s finally posts the REDACTED
3279
+ // `safeMessage`. Retrying with EMPTY_TURN_RETRY_NUDGE would waste the budget
3280
+ // nudging the model about output length when the real fault is the provider;
3281
+ // posting EMPTY_TURN_FALLBACK_TEXT would mask the actual failure behind a
3282
+ // misleading "I got stuck" notice. Return early so the provider-error path
3283
+ // owns this turn's surface.
3284
+ if (leafStopReason === 'error') {
3285
+ logger.warn(`[channels] ${live.keyId} provider_error_turn deferring to provider-error notice`)
3286
+ return
3287
+ }
3288
+
3207
3289
  const skipLockedThisTurn = live.skipLockedSendTurn === live.turnSeq
3208
3290
  const attemptedSendThisTurn = skipLockedThisTurn || live.policyDeniedToolSendsThisTurn.size > 0
3209
3291
 
@@ -3224,11 +3306,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3224
3306
  return
3225
3307
  }
3226
3308
 
3227
- // Only a TRUNCATED assistant leaf (length/error/aborted) from a real
3228
- // conversational turn is a degeneration worth retrying. A cold/empty turn
3229
- // (no inbound author, or no assistant message at all) keeps the historical
3230
- // silent bail re-prompting it would manufacture replies to nothing.
3231
- if (live.currentTurnAuthorId === null || !assistantLeafTruncated(live.session)) {
3309
+ // Only a TRUNCATED assistant leaf — `length` (budget exhaustion) or
3310
+ // `aborted` (terminal-reply abort) — from a real conversational turn is a
3311
+ // degeneration worth retrying. `error` was already diverted above to the
3312
+ // provider-error path. A cold/empty turn (no inbound author, or no
3313
+ // assistant message at all) keeps the historical silent bail —
3314
+ // re-prompting it would manufacture replies to nothing.
3315
+ if (live.currentTurnAuthorId === null || leafStopReason === undefined) {
3232
3316
  logger.info(`[channels] ${live.keyId}: no recoverable assistant text in branch`)
3233
3317
  return
3234
3318
  }
@@ -3236,11 +3320,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3236
3320
  live.emptyTurnRetries++
3237
3321
  // Raise the re-prompt's budget ONLY for a `length` truncation: that is
3238
3322
  // the budget-exhaustion case (reasoning ate the whole pool before any
3239
- // prose), so the retry needs room to finish thinking AND reply. `error`
3240
- // and `aborted` are not budget exhaustion an upstream failure or the
3241
- // terminal-reply abort so they retry under the default backstop.
3242
- // Consumed one-shot by installChannelOutputCap on the next prompt().
3243
- if (assistantLeafStopReason(live.session) === 'length') {
3323
+ // prose), so the retry needs room to finish thinking AND reply. `aborted`
3324
+ // is the terminal-reply abort, not budget exhaustion, so it retries under
3325
+ // the default backstop. Consumed one-shot by installChannelOutputCap on
3326
+ // the next prompt().
3327
+ if (leafStopReason === 'length') {
3244
3328
  live.nextPromptMaxTokens = CHANNEL_EMPTY_TURN_RETRY_MAX_OUTPUT_TOKENS
3245
3329
  }
3246
3330
  logger.warn(
@@ -4615,15 +4699,14 @@ function recoverableAssistantText(
4615
4699
  return null
4616
4700
  }
4617
4701
 
4618
- // The truncation stop reason when the leaf is an assistant message that was CUT
4619
- // OFF mid-output — `length` (hit the token cap, the canonical kimi reasoning-
4620
- // loop), `error`, or `aborted` — else undefined. This is the precise signature
4621
- // of "the model was producing but got truncated", as distinct from a turn that
4622
- // produced no assistant message at all (leaf undefined / a non-assistant
4623
- // entry), which is a benign empty/cold turn. Callers that only need the boolean
4624
- // use `assistantLeafTruncated`; the retry guard reads the reason itself because
4625
- // the raised reasoning budget is justified ONLY for `length` (budget
4626
- // exhaustion), not for `error`/`aborted`.
4702
+ // The non-terminal stop reason when the leaf is an assistant message that did
4703
+ // NOT cleanly finish — `length` (hit the token cap, the canonical kimi
4704
+ // reasoning-loop), `error` (an upstream provider failure), or `aborted` (the
4705
+ // terminal-reply abort) else undefined. `undefined` is the signature of a
4706
+ // benign empty/cold turn (leaf undefined / a non-assistant entry). The
4707
+ // validateChannelTurn recovery path branches on the specific reason: `error`
4708
+ // diverts to the provider-error notice, `length` gets a raised retry budget,
4709
+ // `aborted` retries under the default backstop.
4627
4710
  function assistantLeafStopReason(session: AgentSession): 'length' | 'error' | 'aborted' | undefined {
4628
4711
  const leaf = session.sessionManager.getLeafEntry()
4629
4712
  if (!leaf || leaf.type !== 'message' || leaf.message.role !== 'assistant') return undefined
@@ -4632,10 +4715,6 @@ function assistantLeafStopReason(session: AgentSession): 'length' | 'error' | 'a
4632
4715
  return undefined
4633
4716
  }
4634
4717
 
4635
- function assistantLeafTruncated(session: AgentSession): boolean {
4636
- return assistantLeafStopReason(session) !== undefined
4637
- }
4638
-
4639
4718
  function visibleAssistantText(message: AssistantMessage): string {
4640
4719
  return message.content
4641
4720
  .filter((block) => block.type === 'text')
@@ -29,6 +29,7 @@ import { runLineBootstrap } from '@/init/line-auth'
29
29
  import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
30
30
 
31
31
  import { CANCEL_SYMBOL, promptPrivateKeyPem } from './prompt-pem'
32
+ import { displayQR } from './qr'
32
33
  import { c, done, errorLine, printDiscordInviteHint, printSlackAppManifestSetup } from './ui'
33
34
 
34
35
  const CHANNEL_LABELS: Record<ChannelKind, string> = {
@@ -66,14 +67,15 @@ const addSub = defineCommand({
66
67
 
67
68
  intro(`Adding channel: ${CHANNEL_LABELS[channel]}`)
68
69
 
69
- const credentials = await collectCredentials(channel, cwd)
70
+ const lineSpinnerHolder: LineAuthSpinnerHolder = { current: null }
71
+ const credentials = await collectCredentials(channel, cwd, lineSpinnerHolder)
70
72
 
71
73
  const events: AddChannelStepEvent[] = []
72
74
  try {
73
75
  await runAddChannel({
74
76
  cwd,
75
77
  ...credentials,
76
- onProgress: reportProgress(events),
78
+ onProgress: reportProgress(events, lineSpinnerHolder),
77
79
  })
78
80
  if (credentials.channel === 'github' && credentials.tunnelProvider === 'none') {
79
81
  log.warn(
@@ -256,10 +258,13 @@ async function runReauth(cwd: string, adapter: ReauthableAdapter): Promise<void>
256
258
  }
257
259
 
258
260
  async function runLineReauth(cwd: string): Promise<void> {
259
- const login = await promptLineLogin()
261
+ const holder: LineAuthSpinnerHolder = { current: null }
262
+ const login = await promptLineLogin(holderSpinnerControl(holder))
260
263
  const s = spinner()
261
264
  s.start('Logging in to LINE...')
265
+ holder.current = s
262
266
  const result = await runLineBootstrap({ ...login, agentDir: cwd })
267
+ holder.current = null
263
268
  if (!result.ok) {
264
269
  s.stop(`LINE login failed: ${result.reason}`)
265
270
  process.exit(1)
@@ -607,7 +612,11 @@ type CollectedCredentials =
607
612
  auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
608
613
  }
609
614
 
610
- async function collectCredentials(channel: ChannelKind, cwd: string): Promise<CollectedCredentials> {
615
+ async function collectCredentials(
616
+ channel: ChannelKind,
617
+ cwd: string,
618
+ lineSpinnerHolder?: LineAuthSpinnerHolder,
619
+ ): Promise<CollectedCredentials> {
611
620
  switch (channel) {
612
621
  case 'discord-bot':
613
622
  return { channel, discordBotToken: await promptDiscordToken() }
@@ -618,7 +627,7 @@ async function collectCredentials(channel: ChannelKind, cwd: string): Promise<Co
618
627
  case 'telegram-bot':
619
628
  return { channel, telegramBotToken: await promptTelegramToken() }
620
629
  case 'line': {
621
- const login = await promptLineLogin()
630
+ const login = await promptLineLogin(lineSpinnerHolder ? holderSpinnerControl(lineSpinnerHolder) : undefined)
622
631
  return {
623
632
  channel,
624
633
  runLineAuth: ({ cwd: agentDir }) => runLineBootstrap({ ...login, agentDir }),
@@ -1061,7 +1070,7 @@ async function promptKakaotalkCredentials(
1061
1070
  }
1062
1071
 
1063
1072
  type LinePromptResult =
1064
- | { method: 'qr'; callbacks: { onQRUrl: (url: string) => void; onPincode: (pin: string) => void } }
1073
+ | { method: 'qr'; callbacks: { onQRUrl: (url: string) => Promise<void>; onPincode: (pin: string) => void } }
1065
1074
  | {
1066
1075
  method: 'email'
1067
1076
  email: string
@@ -1069,7 +1078,18 @@ type LinePromptResult =
1069
1078
  callbacks: { onPincode: (pin: string) => void }
1070
1079
  }
1071
1080
 
1072
- async function promptLineLogin(): Promise<LinePromptResult> {
1081
+ // LINE's interactive logins block while the SDK waits on the phone: QR
1082
+ // long-polls for a scan, email/password waits for the user to enter a PIN. The
1083
+ // add-flow spinner ('Logging in to LINE...') keeps animating during that wait
1084
+ // and would otherwise repaint over the multi-line QR or the PIN, garbling both.
1085
+ // This control lets the QR/PIN callbacks pause the live spinner, print legibly,
1086
+ // then resume it with a "waiting for you" message so output stays readable.
1087
+ export type LineAuthSpinnerControl = {
1088
+ pause: () => void
1089
+ resume: (message: string) => void
1090
+ }
1091
+
1092
+ async function promptLineLogin(spinnerControl?: LineAuthSpinnerControl): Promise<LinePromptResult> {
1073
1093
  note(
1074
1094
  [
1075
1095
  'LINE authentication uses a personal account registered as a sub-device.',
@@ -1094,13 +1114,17 @@ async function promptLineLogin(): Promise<LinePromptResult> {
1094
1114
  process.exit(0)
1095
1115
  }
1096
1116
 
1097
- const onPincode = (pin: string): void => log.info(`Enter this PIN in the LINE app to confirm: ${pin}`)
1117
+ const onPincode = (pin: string): void => {
1118
+ spinnerControl?.pause()
1119
+ printLinePincode(pin)
1120
+ spinnerControl?.resume('Waiting for you to confirm the PIN in the LINE app...')
1121
+ }
1098
1122
 
1099
1123
  if (method === 'qr') {
1100
1124
  return {
1101
1125
  method: 'qr',
1102
1126
  callbacks: {
1103
- onQRUrl: (url) => note(url, 'Open this URL on your phone (or scan the QR it renders)'),
1127
+ onQRUrl: (url) => presentLineQr(url, spinnerControl),
1104
1128
  onPincode,
1105
1129
  },
1106
1130
  }
@@ -1125,8 +1149,73 @@ async function promptLineLogin(): Promise<LinePromptResult> {
1125
1149
  return { method: 'email', email, password: pwd, callbacks: { onPincode } }
1126
1150
  }
1127
1151
 
1128
- function reportProgress(events: AddChannelStepEvent[]): (event: AddChannelStepEvent) => void {
1129
- const spinners: Partial<Record<AddChannelStepEvent['step'], ReturnType<typeof spinner>>> = {}
1152
+ async function presentLineQr(url: string, spinnerControl?: LineAuthSpinnerControl): Promise<void> {
1153
+ spinnerControl?.pause()
1154
+ const presentation = await displayQR(url, {
1155
+ title: 'LINE login',
1156
+ scanInstruction: 'Scan with the LINE app on your phone',
1157
+ })
1158
+
1159
+ const lines: string[] = []
1160
+ if (presentation.terminal !== null) {
1161
+ lines.push(presentation.terminal)
1162
+ lines.push('Scan the QR code above with the LINE app on your phone.')
1163
+ lines.push('If it is too small to scan, enlarge the terminal (or zoom out) and re-run.')
1164
+ if (presentation.opened) {
1165
+ lines.push('A browser window with a larger QR code was also opened.')
1166
+ }
1167
+ } else if (presentation.opened) {
1168
+ lines.push('A browser window with the QR code was opened. Scan it with the LINE app on your phone.')
1169
+ } else if (presentation.htmlPath !== null) {
1170
+ lines.push(`Open this file in a browser and scan it with the LINE app: ${presentation.htmlPath}`)
1171
+ }
1172
+ if (lines.length > 0) note(lines.join('\n'), 'Scan to log in to LINE')
1173
+
1174
+ if (presentation.terminal === null && !presentation.opened && presentation.htmlPath === null) {
1175
+ printLineQrUrl(url)
1176
+ }
1177
+
1178
+ spinnerControl?.resume('Waiting for you to scan the QR code with the LINE app...')
1179
+ }
1180
+
1181
+ // Last-resort fallback when no QR could be rendered. The raw URL stays OUT of
1182
+ // note(): clack wraps long lines with a `│` gutter that corrupts the login URL
1183
+ // (it carries `?secret=...&e2eeVersion=...`). Same constraint as
1184
+ // src/cli/oauth-callbacks.ts.
1185
+ export function printLineQrUrl(url: string, output: NodeJS.WritableStream = process.stdout): void {
1186
+ note('Open this URL on a device that can render it as a QR code:', 'Log in to LINE')
1187
+ output.write(`${url}\n\n`)
1188
+ }
1189
+
1190
+ // PIN stays OUT of the note() gutter (same `│`-splitting reason as
1191
+ // printLineQrUrl) and must stand out: the SDK blocks on phone confirmation and
1192
+ // throws if its window lapses, so a PIN scrolled past unnoticed leaves nothing
1193
+ // in secrets.json — the root cause of the runtime "No account found" error. The
1194
+ // "waiting" line is emitted by the resumed spinner, not here.
1195
+ export function printLinePincode(pin: string, output: NodeJS.WritableStream = process.stdout): void {
1196
+ note('Open the LINE app on your phone and enter this PIN to authorize this device:', 'Confirm LINE login')
1197
+ output.write(`\n PIN: ${pin}\n\n`)
1198
+ }
1199
+
1200
+ type Spinner = ReturnType<typeof spinner>
1201
+
1202
+ // `current` is the live `line-auth` spinner, shared between `reportProgress`
1203
+ // (which owns its lifecycle) and the QR/PIN callbacks (which must pause it to
1204
+ // print legibly). It is null whenever no LINE login spinner is active.
1205
+ export type LineAuthSpinnerHolder = { current: Spinner | null }
1206
+
1207
+ export function holderSpinnerControl(holder: LineAuthSpinnerHolder): LineAuthSpinnerControl {
1208
+ return {
1209
+ pause: () => holder.current?.stop(),
1210
+ resume: (message) => holder.current?.start(message),
1211
+ }
1212
+ }
1213
+
1214
+ function reportProgress(
1215
+ events: AddChannelStepEvent[],
1216
+ lineSpinnerHolder?: LineAuthSpinnerHolder,
1217
+ ): (event: AddChannelStepEvent) => void {
1218
+ const spinners: Partial<Record<AddChannelStepEvent['step'], Spinner>> = {}
1130
1219
 
1131
1220
  return (event) => {
1132
1221
  events.push(event)
@@ -1134,6 +1223,7 @@ function reportProgress(events: AddChannelStepEvent[]): (event: AddChannelStepEv
1134
1223
  const s = spinner()
1135
1224
  s.start(START_MESSAGES[event.step])
1136
1225
  spinners[event.step] = s
1226
+ if (event.step === 'line-auth' && lineSpinnerHolder) lineSpinnerHolder.current = s
1137
1227
  return
1138
1228
  }
1139
1229
 
@@ -1142,6 +1232,7 @@ function reportProgress(events: AddChannelStepEvent[]): (event: AddChannelStepEv
1142
1232
 
1143
1233
  switch (event.step) {
1144
1234
  case 'line-auth':
1235
+ if (lineSpinnerHolder) lineSpinnerHolder.current = null
1145
1236
  s.stop(reportLineAuth(event.result))
1146
1237
  break
1147
1238
  case 'kakaotalk-auth':
package/src/cli/qr.ts ADDED
@@ -0,0 +1,130 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { promisify } from 'node:util'
6
+
7
+ import QRCode from 'qrcode'
8
+
9
+ const execFileAsync = promisify(execFile)
10
+
11
+ // The upstream LINE SDK's QR login hands back a raw auth URL
12
+ // (https://line.me/R/au/q/...), which is not scannable on its own — the LINE
13
+ // mobile app needs an actual QR image. This module renders that URL the way the
14
+ // upstream `agent-line` CLI does: an HTML page opened in the browser plus, on a
15
+ // TTY, an inline ASCII QR. Every external effect is best-effort so a non-TTY or
16
+ // browserless host degrades to a machine-readable scan payload rather than
17
+ // blocking login.
18
+
19
+ export type QRPresentation = {
20
+ qrUrl: string
21
+ htmlPath: string | null
22
+ terminal: string | null
23
+ opened: boolean
24
+ }
25
+
26
+ export type DisplayQROptions = {
27
+ title: string
28
+ scanInstruction: string
29
+ brandColor?: string
30
+ isTty?: boolean
31
+ opener?: (filePath: string) => Promise<void>
32
+ tmpDir?: string
33
+ now?: () => number
34
+ }
35
+
36
+ export async function buildQRHtml(
37
+ url: string,
38
+ options: { title: string; scanInstruction: string; brandColor: string },
39
+ ): Promise<string> {
40
+ const svg = await QRCode.toString(url, { type: 'svg', margin: 2 })
41
+ const title = escapeHtml(options.title)
42
+ const instruction = escapeHtml(options.scanInstruction)
43
+ return `<!DOCTYPE html>
44
+ <html><head><meta charset="utf-8"><title>${title}</title>
45
+ <style>body{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;font-family:-apple-system,system-ui,sans-serif;background:${options.brandColor}}
46
+ .card{background:#fff;border-radius:16px;padding:40px;text-align:center;box-shadow:0 4px 24px rgba(0,0,0,.15)}
47
+ h1{margin:0 0 8px;font-size:22px;color:#111}p{margin:0 0 24px;color:#666;font-size:14px}
48
+ svg{width:280px;height:280px}</style></head>
49
+ <body><div class="card"><h1>${title}</h1><p>${instruction}</p>${svg}</div></body></html>`
50
+ }
51
+
52
+ export async function renderTerminalQR(url: string): Promise<string> {
53
+ // 'L' error correction yields the fewest modules, so the QR stays small
54
+ // enough to scan from a terminal over SSH where screen real estate is the
55
+ // binding constraint. A short-lived auth URL doesn't need higher ECC.
56
+ return QRCode.toString(url, { type: 'terminal', small: true, errorCorrectionLevel: 'L' })
57
+ }
58
+
59
+ // Writes the QR HTML to a temp file and tries to open it in the default
60
+ // browser, and (when on a TTY) renders an inline ASCII QR. Every external
61
+ // effect is best-effort: a failure to write, open, or render degrades the
62
+ // result rather than throwing, so login is never blocked by presentation.
63
+ export async function displayQR(url: string, options: DisplayQROptions): Promise<QRPresentation> {
64
+ const brandColor = options.brandColor ?? '#06C755'
65
+ const isTty = options.isTty ?? process.stderr.isTTY === true
66
+ const now = options.now ?? Date.now
67
+ const dir = options.tmpDir ?? tmpdir()
68
+
69
+ const htmlPath = await writeQRHtmlFile(url, {
70
+ title: options.title,
71
+ scanInstruction: options.scanInstruction,
72
+ brandColor,
73
+ dir,
74
+ stamp: now(),
75
+ })
76
+
77
+ let opened = false
78
+ if (htmlPath !== null) {
79
+ const open = options.opener ?? openInBrowser
80
+ opened = await open(htmlPath).then(
81
+ () => true,
82
+ () => false,
83
+ )
84
+ }
85
+
86
+ const terminal = isTty ? await renderTerminalQR(url).catch(() => null) : null
87
+
88
+ return { qrUrl: url, htmlPath, terminal, opened }
89
+ }
90
+
91
+ async function writeQRHtmlFile(
92
+ url: string,
93
+ options: { title: string; scanInstruction: string; brandColor: string; dir: string; stamp: number },
94
+ ): Promise<string | null> {
95
+ try {
96
+ const html = await buildQRHtml(url, options)
97
+ const slug =
98
+ options.title
99
+ .toLowerCase()
100
+ .replace(/[^a-z0-9]+/g, '-')
101
+ .replace(/^-+|-+$/g, '') || 'qr'
102
+ const htmlPath = join(options.dir, `typeclaw-${slug}-${options.stamp}.html`)
103
+ await writeFile(htmlPath, html, { mode: 0o600 })
104
+ return htmlPath
105
+ } catch {
106
+ return null
107
+ }
108
+ }
109
+
110
+ async function openInBrowser(filePath: string): Promise<void> {
111
+ const platform = process.platform
112
+ if (platform === 'darwin') {
113
+ await execFileAsync('open', [filePath])
114
+ return
115
+ }
116
+ if (platform === 'win32') {
117
+ await execFileAsync('cmd', ['/c', 'start', '', filePath])
118
+ return
119
+ }
120
+ await execFileAsync('xdg-open', [filePath])
121
+ }
122
+
123
+ function escapeHtml(value: string): string {
124
+ return value
125
+ .replace(/&/g, '&amp;')
126
+ .replace(/</g, '&lt;')
127
+ .replace(/>/g, '&gt;')
128
+ .replace(/"/g, '&quot;')
129
+ .replace(/'/g, '&#39;')
130
+ }