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.
- package/package.json +3 -1
- package/src/agent/plugin-tools.ts +71 -13
- package/src/agent/provider-error.ts +10 -0
- package/src/agent/session-origin.ts +26 -0
- package/src/agent/tools/channel-disengage.ts +13 -9
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +124 -6
- package/src/bundled-plugins/github-cli-auth/git-command.ts +172 -26
- package/src/bundled-plugins/github-cli-auth/index.ts +46 -7
- package/src/bundled-plugins/github-cli-auth/token-class.ts +13 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +33 -2
- package/src/channels/adapters/github/inbound.ts +41 -3
- package/src/channels/adapters/slack-bot.ts +17 -9
- package/src/channels/continuation-willingness.ts +331 -0
- package/src/channels/github-review-claim.ts +105 -0
- package/src/channels/github-token-bridge.ts +7 -0
- package/src/channels/router.ts +103 -24
- package/src/cli/channel.ts +102 -11
- package/src/cli/qr.ts +130 -0
- package/src/config/config.ts +98 -2
- package/src/container/start.ts +12 -0
- package/src/init/dockerfile.ts +64 -0
- package/src/init/line-auth.ts +8 -3
- package/src/inspect/live.ts +128 -13
- package/src/plugin/context.ts +5 -1
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/types.ts +1 -0
- package/src/run/index.ts +1 -0
- package/src/sandbox/availability.ts +87 -19
- package/src/sandbox/build.ts +27 -0
- package/src/sandbox/index.ts +10 -0
- package/src/sandbox/package-install.ts +23 -0
- package/src/sandbox/policy.ts +31 -0
- package/src/sandbox/symlinks.ts +34 -0
- package/src/sandbox/writable-zones.ts +164 -4
- package/src/server/index.ts +5 -1
- package/src/shared/protocol.ts +22 -11
- package/src/skills/typeclaw-channel-github/SKILL.md +4 -2
- package/src/skills/typeclaw-github-contributing/SKILL.md +124 -0
- package/typeclaw.schema.json +32 -1
package/src/channels/router.ts
CHANGED
|
@@ -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)
|
|
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
|
|
3228
|
-
//
|
|
3229
|
-
//
|
|
3230
|
-
//
|
|
3231
|
-
|
|
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. `
|
|
3240
|
-
//
|
|
3241
|
-
//
|
|
3242
|
-
//
|
|
3243
|
-
if (
|
|
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
|
|
4619
|
-
//
|
|
4620
|
-
// loop), `error
|
|
4621
|
-
//
|
|
4622
|
-
//
|
|
4623
|
-
//
|
|
4624
|
-
//
|
|
4625
|
-
//
|
|
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')
|
package/src/cli/channel.ts
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 =>
|
|
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) =>
|
|
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
|
|
1129
|
-
|
|
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, '&')
|
|
126
|
+
.replace(/</g, '<')
|
|
127
|
+
.replace(/>/g, '>')
|
|
128
|
+
.replace(/"/g, '"')
|
|
129
|
+
.replace(/'/g, ''')
|
|
130
|
+
}
|