typeclaw 0.10.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.
Files changed (62) hide show
  1. package/README.md +5 -1
  2. package/package.json +1 -1
  3. package/src/agent/index.ts +37 -4
  4. package/src/agent/multimodal/look-at.ts +8 -0
  5. package/src/agent/restart-handoff/index.ts +91 -0
  6. package/src/agent/restart-handoff/paths.ts +11 -0
  7. package/src/agent/session-origin.ts +30 -10
  8. package/src/agent/subagent-completion-reminder.ts +4 -2
  9. package/src/agent/system-prompt.ts +3 -1
  10. package/src/agent/tools/restart.ts +42 -1
  11. package/src/agent/tools/skip-response.ts +157 -0
  12. package/src/bundled-plugins/memory/README.md +18 -2
  13. package/src/bundled-plugins/memory/index.ts +108 -6
  14. package/src/bundled-plugins/memory/memory-logger.ts +33 -24
  15. package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
  16. package/src/channels/adapters/discord-bot-invite.ts +89 -0
  17. package/src/channels/adapters/github/auth-app.ts +53 -9
  18. package/src/channels/adapters/github/auth-pat.ts +4 -1
  19. package/src/channels/adapters/github/auth.ts +10 -0
  20. package/src/channels/adapters/github/event-permissions.ts +83 -0
  21. package/src/channels/adapters/github/inbound.ts +126 -1
  22. package/src/channels/adapters/github/index.ts +60 -66
  23. package/src/channels/adapters/github/outbound.ts +65 -17
  24. package/src/channels/adapters/github/permission-guidance.ts +169 -0
  25. package/src/channels/adapters/github/team-membership.ts +56 -0
  26. package/src/channels/adapters/kakaotalk-classify.ts +13 -1
  27. package/src/channels/adapters/kakaotalk.ts +2 -0
  28. package/src/channels/router.ts +269 -34
  29. package/src/channels/schema.ts +8 -7
  30. package/src/channels/types.ts +1 -1
  31. package/src/cli/channel.ts +138 -52
  32. package/src/cli/init.ts +139 -100
  33. package/src/cli/inspect-controller.ts +66 -0
  34. package/src/cli/inspect.ts +24 -32
  35. package/src/cli/prompt-pem.ts +113 -0
  36. package/src/cli/run.ts +24 -5
  37. package/src/cli/tui.ts +34 -10
  38. package/src/cli/tunnel.ts +453 -14
  39. package/src/cli/ui.ts +22 -0
  40. package/src/compose/discover.ts +5 -0
  41. package/src/config/config.ts +35 -7
  42. package/src/config/providers.ts +64 -56
  43. package/src/init/env-file.ts +66 -0
  44. package/src/init/hatching.ts +32 -5
  45. package/src/init/index.ts +131 -39
  46. package/src/init/validate-api-key.ts +31 -0
  47. package/src/inspect/index.ts +5 -1
  48. package/src/inspect/loop.ts +12 -1
  49. package/src/inspect/replay.ts +15 -1
  50. package/src/run/codex-fetch-observer.ts +377 -0
  51. package/src/run/index.ts +14 -2
  52. package/src/server/command-runner.ts +31 -2
  53. package/src/server/index.ts +59 -1
  54. package/src/shared/protocol.ts +1 -1
  55. package/src/skills/typeclaw-channel-github/SKILL.md +47 -1
  56. package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
  57. package/src/tui/index.ts +17 -5
  58. package/src/tunnels/index.ts +1 -0
  59. package/src/tunnels/manager.ts +18 -0
  60. package/src/tunnels/providers/cloudflare-named.ts +224 -0
  61. package/src/tunnels/types.ts +17 -1
  62. package/typeclaw.schema.json +25 -7
package/src/cli/init.ts CHANGED
@@ -1,12 +1,10 @@
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'
6
5
 
7
6
  import {
8
7
  KNOWN_PROVIDERS,
9
- providerForModelRef,
10
8
  supportsApiKey as providerSupportsApiKey,
11
9
  supportsOAuth as providerSupportsOAuth,
12
10
  type KnownModelRef,
@@ -14,9 +12,12 @@ import {
14
12
  } from '@/config/providers'
15
13
  import { checkDockerAvailable, type DockerAvailability } from '@/container'
16
14
  import {
15
+ appendOrReplaceEnvKey,
17
16
  findAgentDir,
18
17
  formatEagerGithubWebhookInstallResult,
18
+ hasEnvKey,
19
19
  hasExistingChannelSecrets,
20
+ hasExistingOAuthCredentials,
20
21
  isDirectoryNonEmpty,
21
22
  isHatched,
22
23
  readExistingProviderApiKey,
@@ -34,7 +35,8 @@ import { makeOAuthLoginRunner, type OAuthLoginResult } from '@/init/oauth-login'
34
35
  import { API_KEY_DASHBOARD_URL, validateApiKey, type KeyValidationResult } from '@/init/validate-api-key'
35
36
 
36
37
  import { buildOAuthCallbacks } from './oauth-callbacks'
37
- import { c, done, errorLine, printSlackAppManifestSetup } from './ui'
38
+ import { CANCEL_SYMBOL, promptPrivateKeyPem } from './prompt-pem'
39
+ import { c, done, errorLine, printDiscordInviteHint, printSlackAppManifestSetup } from './ui'
38
40
 
39
41
  // ESC and Ctrl+C both produce clack's cancel symbol (the keypress layer
40
42
  // aliases both to the same "cancel" action — there's no way to tell them
@@ -79,8 +81,17 @@ export const init = defineCommand({
79
81
  name: 'init',
80
82
  description: 'initialize a new typeclaw agent in the current directory',
81
83
  },
82
- async run() {
84
+ args: {
85
+ reset: {
86
+ type: 'boolean',
87
+ description:
88
+ 'ignore any partial secrets.json state from an earlier aborted run and re-prompt for every credential',
89
+ default: false,
90
+ },
91
+ },
92
+ async run({ args }) {
83
93
  const cwd = process.cwd()
94
+ const reset = args.reset === true
84
95
 
85
96
  const existingAgent = findAgentDir(cwd)
86
97
  if (existingAgent !== null && existingAgent !== cwd) {
@@ -129,7 +140,7 @@ export const init = defineCommand({
129
140
 
130
141
  let collected: CollectedInputs
131
142
  try {
132
- collected = await collectWizardInputs(cwd, defaultWizardPrompts)
143
+ collected = await collectWizardInputs(cwd, defaultWizardPrompts, { reset })
133
144
  } catch (error) {
134
145
  if (error instanceof WizardAbortedError) {
135
146
  if (error.oauthCredentialsSaved) {
@@ -330,17 +341,13 @@ type StepId =
330
341
  export interface WizardPrompts {
331
342
  loadCatalog: () => Promise<NonNullable<WizardState['catalog']>>
332
343
  readExistingApiKey: (cwd: string, providerId: KnownProviderId) => Promise<string | null>
344
+ hasExistingOAuthCredentials: (cwd: string, providerId: KnownProviderId) => Promise<boolean>
333
345
  pickProvider: (options: ModelOption[], initial: KnownProviderId | undefined) => Promise<StepResult<KnownProviderId>>
334
346
  pickModel: (
335
347
  options: ModelOption[],
336
348
  providerId: KnownProviderId,
337
349
  initial: KnownModelRef | undefined,
338
350
  ) => Promise<StepResult<ModelOption>>
339
- askReuseExistingKey: (
340
- provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
341
- existingApiKey: string | null,
342
- initial: boolean | undefined,
343
- ) => Promise<StepResult<'reuse' | 'prompt'>>
344
351
  pickAuthMethod: (
345
352
  provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
346
353
  initial: 'api-key' | 'oauth' | undefined,
@@ -358,17 +365,12 @@ export interface WizardPrompts {
358
365
  ) => Promise<StepResult<ModelOption>>
359
366
  pickChannel: (initial: ChannelChoice | undefined) => Promise<StepResult<ChannelChoice>>
360
367
  hasExistingChannelSecrets: (cwd: string, channel: Exclude<ChannelChoice, 'none'>) => Promise<boolean>
361
- askReuseExistingChannel: (channel: Exclude<ChannelChoice, 'none'>) => Promise<StepResult<'reuse' | 'prompt'>>
362
- runChannelFlow: (choice: ChannelChoice) => Promise<StepResult<CollectedInputs['channelSecrets']>>
368
+ runChannelFlow: (choice: ChannelChoice, cwd: string) => Promise<StepResult<CollectedInputs['channelSecrets']>>
363
369
  runOAuthLogin: (
364
370
  provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
365
371
  cwd: string,
366
372
  model: KnownModelRef,
367
373
  ) => Promise<OAuthLoginResult>
368
- // Asked after a failed OAuth login. `apiKeyAvailable` is true when the
369
- // provider also supports api-key auth (so the wizard can offer a fallback
370
- // path); false for OAuth-only providers like openai-codex, where the only
371
- // options are retry or abort.
372
374
  askOAuthFailureRecovery: (
373
375
  provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
374
376
  reason: string,
@@ -376,14 +378,18 @@ export interface WizardPrompts {
376
378
  ) => Promise<OAuthFailureRecovery>
377
379
  }
378
380
 
381
+ export type CollectWizardInputsOptions = {
382
+ reset?: boolean
383
+ }
384
+
379
385
  export type OAuthFailureRecovery = 'retry' | 'api-key' | 'abort'
380
386
 
381
387
  export const defaultWizardPrompts: WizardPrompts = {
382
388
  loadCatalog,
383
389
  readExistingApiKey: readExistingProviderApiKey,
390
+ hasExistingOAuthCredentials,
384
391
  pickProvider,
385
392
  pickModel: pickModelForProvider,
386
- askReuseExistingKey,
387
393
  pickAuthMethod,
388
394
  askApiKey,
389
395
  validateApiKey,
@@ -391,7 +397,6 @@ export const defaultWizardPrompts: WizardPrompts = {
391
397
  pickVisionModel,
392
398
  pickChannel,
393
399
  hasExistingChannelSecrets,
394
- askReuseExistingChannel,
395
400
  runChannelFlow,
396
401
  runOAuthLogin: async (provider, cwd, model) => {
397
402
  const { callbacks, dispose } = buildOAuthCallbacks(provider.name)
@@ -404,7 +409,12 @@ export const defaultWizardPrompts: WizardPrompts = {
404
409
  askOAuthFailureRecovery,
405
410
  }
406
411
 
407
- export async function collectWizardInputs(cwd: string, prompts: WizardPrompts): Promise<CollectedInputs> {
412
+ export async function collectWizardInputs(
413
+ cwd: string,
414
+ prompts: WizardPrompts,
415
+ options: CollectWizardInputsOptions = {},
416
+ ): Promise<CollectedInputs> {
417
+ const reset = options.reset === true
408
418
  const catalog = await prompts.loadCatalog()
409
419
  const state: WizardState = { catalog }
410
420
  let step: StepId = 'pick-provider'
@@ -425,6 +435,21 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
425
435
  return result
426
436
  }
427
437
 
438
+ const readExistingApiKey = async (providerId: KnownProviderId): Promise<string | null> => {
439
+ if (reset) return null
440
+ return await prompts.readExistingApiKey(cwd, providerId)
441
+ }
442
+
443
+ const hasExistingOAuth = async (providerId: KnownProviderId): Promise<boolean> => {
444
+ if (reset) return false
445
+ return await prompts.hasExistingOAuthCredentials(cwd, providerId)
446
+ }
447
+
448
+ const hasExistingChannel = async (channel: Exclude<ChannelChoice, 'none'>): Promise<boolean> => {
449
+ if (reset) return false
450
+ return await prompts.hasExistingChannelSecrets(cwd, channel)
451
+ }
452
+
428
453
  while (true) {
429
454
  switch (step) {
430
455
  case 'pick-provider': {
@@ -456,17 +481,15 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
456
481
 
457
482
  case 'reuse-existing-key': {
458
483
  const provider = KNOWN_PROVIDERS[state.providerId!]
459
- const existingApiKey = await prompts.readExistingApiKey(cwd, state.providerId!)
460
- const decision = onResult(
461
- step,
462
- await prompts.askReuseExistingKey(provider, existingApiKey, state.reuseExisting),
463
- )
464
- if (decision.kind === 'back') {
465
- step = 'pick-model'
466
- break
467
- }
468
- if (decision.value === 'reuse' && existingApiKey !== null) {
469
- log.info(`Using existing ${provider.name} API key from secrets.json.`)
484
+ // Auto-resume: if `secrets.json` already has a usable api-key for
485
+ // this provider, reuse it silently. Issue #330: re-running init
486
+ // after a partial abort should pick up where the user left off
487
+ // without re-prompting for credentials they already supplied.
488
+ // `--reset` bypasses this by making `readExistingApiKey` return
489
+ // null, falling through to the normal auth-method flow.
490
+ const existingApiKey = await readExistingApiKey(state.providerId!)
491
+ if (existingApiKey !== null) {
492
+ log.info(`Reusing existing ${provider.name} API key from secrets.json.`)
470
493
  state.llmAuth = { kind: 'api-key', apiKey: existingApiKey }
471
494
  state.reuseExisting = true
472
495
  step = stepAfterDefaultAuth(state)
@@ -482,11 +505,29 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
482
505
  const provider = KNOWN_PROVIDERS[state.providerId!]
483
506
  const result = onResult(step, await prompts.pickAuthMethod(provider, state.authMethod))
484
507
  if (result.kind === 'back') {
485
- step = 'reuse-existing-key'
508
+ // Skip past `reuse-existing-key` — it is a silent auto-resume
509
+ // step with no user prompt, so unwind directly to pick-model
510
+ // (the prior user-visible step). This only fires when
511
+ // pickAuthMethod was an interactive choice (dual-auth providers);
512
+ // single-method providers return autoValue and never reach the
513
+ // back branch.
514
+ step = 'pick-model'
486
515
  break
487
516
  }
488
517
  state.authMethod = result.value
489
518
  if (result.value === 'oauth') {
519
+ // Auto-resume: skip the browser flow when OAuth credentials are
520
+ // already on disk from a prior partial run. The fresh-tokens path
521
+ // and the resume path both leave `state.llmAuth = oauth-completed`,
522
+ // so downstream steps (vision, channel, scaffold) can't tell the
523
+ // difference. `--reset` short-circuits this by making
524
+ // `hasExistingOAuth` return false.
525
+ if (await hasExistingOAuth(state.providerId!)) {
526
+ log.info(`Reusing existing ${provider.name} OAuth credentials from secrets.json.`)
527
+ state.llmAuth = { kind: 'oauth-completed' }
528
+ step = stepAfterDefaultAuth(state)
529
+ break
530
+ }
490
531
  // Run the browser login eagerly so the user sees the OAuth URL the
491
532
  // moment they pick "OAuth (browser login)" — not at the end of the
492
533
  // wizard. On failure we ask the user how to recover (retry / fall
@@ -583,9 +624,9 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
583
624
  step = 'pick-channel'
584
625
  break
585
626
  }
586
- const existingVisionKey = await prompts.readExistingApiKey(cwd, state.visionProviderId!)
627
+ const existingVisionKey = await readExistingApiKey(state.visionProviderId!)
587
628
  if (existingVisionKey !== null) {
588
- log.info(`Using existing ${KNOWN_PROVIDERS[state.visionProviderId!].name} API key from secrets.json.`)
629
+ log.info(`Reusing existing ${KNOWN_PROVIDERS[state.visionProviderId!].name} API key from secrets.json.`)
589
630
  state.visionLlmAuth = { kind: 'api-key', apiKey: existingVisionKey }
590
631
  state.visionReuseExisting = true
591
632
  step = 'pick-channel'
@@ -606,6 +647,16 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
606
647
  }
607
648
  state.visionAuthMethod = result.value
608
649
  if (result.value === 'oauth') {
650
+ // Auto-resume mirror of the default-provider branch above: skip
651
+ // the browser flow when vision OAuth credentials are already on
652
+ // disk. The same `--reset` short-circuit applies via
653
+ // `hasExistingOAuth`.
654
+ if (await hasExistingOAuth(state.visionProviderId!)) {
655
+ log.info(`Reusing existing ${provider.name} OAuth credentials from secrets.json.`)
656
+ state.visionLlmAuth = { kind: 'oauth-completed' }
657
+ step = 'pick-channel'
658
+ break
659
+ }
609
660
  // Same eager-login + recovery-prompt rationale as the default-provider branch above.
610
661
  const login = await runOAuthLoginSafely(prompts, provider, cwd, state.visionModel!.ref)
611
662
  if (!login.ok) {
@@ -667,31 +718,26 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
667
718
 
668
719
  case 'reuse-existing-channel': {
669
720
  const choice = state.channelChoice as Exclude<ChannelChoice, 'none'>
670
- const present = await prompts.hasExistingChannelSecrets(cwd, choice)
721
+ // Auto-resume: when usable channel credentials already exist on
722
+ // disk, reuse them silently — mirrors the api-key and OAuth
723
+ // resume paths above. `--reset` short-circuits via
724
+ // `hasExistingChannel` returning false, falling through to the
725
+ // normal channel-flow prompts.
726
+ const present = await hasExistingChannel(choice)
671
727
  if (!present) {
672
728
  state.channelReuseOffered = false
673
729
  state.channelReuseExisting = false
674
730
  step = 'channel-flow'
675
731
  break
676
732
  }
733
+ log.info(`Reusing existing ${channelDisplayName(choice)} credentials from secrets.json.`)
677
734
  state.channelReuseOffered = true
678
- const decision = onResult(step, await prompts.askReuseExistingChannel(choice))
679
- if (decision.kind === 'back') {
680
- step = 'pick-channel'
681
- break
682
- }
683
- if (decision.value === 'reuse') {
684
- log.info(`Using existing ${channelDisplayName(choice)} credentials from secrets.json.`)
685
- state.channelReuseExisting = true
686
- return finalize(state, {})
687
- }
688
- state.channelReuseExisting = false
689
- step = 'channel-flow'
690
- break
735
+ state.channelReuseExisting = true
736
+ return finalize(state, {})
691
737
  }
692
738
 
693
739
  case 'channel-flow': {
694
- const result = onResult(step, await prompts.runChannelFlow(state.channelChoice!))
740
+ const result = onResult(step, await prompts.runChannelFlow(state.channelChoice!, cwd))
695
741
  if (result.kind === 'back') {
696
742
  step = state.channelReuseOffered === true ? 'reuse-existing-channel' : 'pick-channel'
697
743
  break
@@ -818,31 +864,6 @@ async function pickModelForProvider(
818
864
  return value(picked)
819
865
  }
820
866
 
821
- async function askReuseExistingKey(
822
- provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
823
- existingApiKey: string | null,
824
- initial: boolean | undefined,
825
- ): Promise<StepResult<'reuse' | 'prompt'>> {
826
- if (!providerSupportsApiKey(provider) || existingApiKey === null) return value('prompt')
827
- const reuse = await confirm({
828
- message: `Reuse existing ${provider.name} API key from secrets.json?`,
829
- initialValue: initial ?? true,
830
- })
831
- if (isCancel(reuse)) return back()
832
- return value(reuse === true ? 'reuse' : 'prompt')
833
- }
834
-
835
- async function askReuseExistingChannel(
836
- channel: Exclude<ChannelChoice, 'none'>,
837
- ): Promise<StepResult<'reuse' | 'prompt'>> {
838
- const reuse = await confirm({
839
- message: `Reuse existing ${channelDisplayName(channel)} credentials from secrets.json?`,
840
- initialValue: true,
841
- })
842
- if (isCancel(reuse)) return back()
843
- return value(reuse === true ? 'reuse' : 'prompt')
844
- }
845
-
846
867
  async function pickAuthMethod(
847
868
  provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
848
869
  initial: 'api-key' | 'oauth' | undefined,
@@ -1011,7 +1032,10 @@ async function pickChannel(initial: ChannelChoice | undefined): Promise<StepResu
1011
1032
  return value(choice)
1012
1033
  }
1013
1034
 
1014
- async function runChannelFlow(choice: ChannelChoice): Promise<StepResult<CollectedInputs['channelSecrets']>> {
1035
+ async function runChannelFlow(
1036
+ choice: ChannelChoice,
1037
+ cwd: string,
1038
+ ): Promise<StepResult<CollectedInputs['channelSecrets']>> {
1015
1039
  switch (choice) {
1016
1040
  case 'none':
1017
1041
  return value({})
@@ -1024,7 +1048,7 @@ async function runChannelFlow(choice: ChannelChoice): Promise<StepResult<Collect
1024
1048
  case 'telegram':
1025
1049
  return runTelegramFlow()
1026
1050
  case 'github':
1027
- return runGithubFlow()
1051
+ return runGithubFlow(cwd)
1028
1052
  }
1029
1053
  }
1030
1054
 
@@ -1042,6 +1066,7 @@ async function runDiscordFlow(): Promise<StepResult<CollectedInputs['channelSecr
1042
1066
  validate: (v) => (v && v.length > 0 ? undefined : 'Token is required'),
1043
1067
  })
1044
1068
  if (isCancel(token)) return back()
1069
+ printDiscordInviteHint(token)
1045
1070
  return value({ discordBotToken: token })
1046
1071
  }
1047
1072
 
@@ -1144,7 +1169,7 @@ async function runSlackFlow(): Promise<StepResult<CollectedInputs['channelSecret
1144
1169
  }
1145
1170
  }
1146
1171
 
1147
- async function runGithubFlow(): Promise<StepResult<CollectedInputs['channelSecrets']>> {
1172
+ async function runGithubFlow(cwd: string): Promise<StepResult<CollectedInputs['channelSecrets']>> {
1148
1173
  note(
1149
1174
  [
1150
1175
  'Choose PAT auth for a quick setup, or GitHub App auth for expiring installation tokens.',
@@ -1171,6 +1196,10 @@ async function runGithubFlow(): Promise<StepResult<CollectedInputs['channelSecre
1171
1196
  value: 'cloudflare-quick',
1172
1197
  label: 'Cloudflare Quick Tunnel — no signup, URL rotates on restart (recommended)',
1173
1198
  },
1199
+ {
1200
+ value: 'cloudflare-named',
1201
+ label: 'Cloudflare Named Tunnel — stable URL, needs Cloudflare account + domain',
1202
+ },
1174
1203
  { value: 'external', label: 'External URL — I have my own reverse proxy / tunnel' },
1175
1204
  { value: 'none', label: 'None — configure later by hand-editing typeclaw.json' },
1176
1205
  ],
@@ -1185,6 +1214,8 @@ async function runGithubFlow(): Promise<StepResult<CollectedInputs['channelSecre
1185
1214
  })
1186
1215
  : undefined
1187
1216
  if (isCancel(webhookUrl)) return back()
1217
+ const namedCreds = tunnelProvider === 'cloudflare-named' ? await promptGithubCloudflareNamedTunnel(cwd) : undefined
1218
+ if (namedCreds === null) return back()
1188
1219
  const port = await text({
1189
1220
  message: 'Local webhook port inside the agent container',
1190
1221
  initialValue: '8975',
@@ -1214,12 +1245,41 @@ async function runGithubFlow(): Promise<StepResult<CollectedInputs['channelSecre
1214
1245
  tunnelProvider,
1215
1246
  ...(webhookUrl !== undefined ? { webhookUrl } : {}),
1216
1247
  webhookPort: Number(port),
1248
+ ...(namedCreds !== undefined ? namedCreds : {}),
1217
1249
  repos: parseGithubRepos(reposRaw),
1218
1250
  auth,
1219
1251
  },
1220
1252
  })
1221
1253
  }
1222
1254
 
1255
+ async function promptGithubCloudflareNamedTunnel(cwd: string): Promise<{ hostname: string; tokenEnv: string } | null> {
1256
+ const tokenEnv = 'CLOUDFLARE_TUNNEL_TOKEN'
1257
+ note(
1258
+ [
1259
+ 'Cloudflare Named Tunnel needs a tunnel you created in the Zero Trust dashboard:',
1260
+ ' 1. Networks → Tunnels → Create a tunnel → Cloudflared. Copy the token shown on the install screen.',
1261
+ ' 2. Public Hostname tab → Add: subdomain + your-domain, service type HTTP, URL localhost:<webhook port>.',
1262
+ ` 3. Paste the token below when prompted — TypeClaw will write it to .env as ${tokenEnv}.`,
1263
+ 'A tunnel without a Public Hostname registers but routes nothing.',
1264
+ ].join('\n'),
1265
+ 'Cloudflare named tunnel',
1266
+ )
1267
+ const hostname = await text({
1268
+ message: 'Public hostname configured in the dashboard (https://...)',
1269
+ validate: (v) => validateGithubUrl(v ?? '', 'Hostname is required'),
1270
+ })
1271
+ if (isCancel(hostname)) return null
1272
+ if (!hasEnvKey(cwd, tokenEnv)) {
1273
+ const token = await password({
1274
+ message: `Cloudflare tunnel token (will be written to .env as ${tokenEnv})`,
1275
+ validate: (v) => (v && v.length > 0 ? undefined : 'Token is required'),
1276
+ })
1277
+ if (isCancel(token)) return null
1278
+ appendOrReplaceEnvKey(cwd, tokenEnv, token)
1279
+ }
1280
+ return { hostname, tokenEnv }
1281
+ }
1282
+
1223
1283
  async function promptGithubPatAuth(): Promise<{ type: 'pat'; pat: string } | null> {
1224
1284
  const pat = await password({
1225
1285
  message: 'GitHub fine-grained PAT',
@@ -1240,11 +1300,8 @@ async function promptGithubAppAuth(): Promise<{
1240
1300
  validate: (v) => validatePositiveInteger(v ?? '', 'App ID is required'),
1241
1301
  })
1242
1302
  if (isCancel(appId)) return null
1243
- const privateKeyInput = await text({
1244
- message: 'GitHub App private key PEM, escaped PEM, or path to .pem file',
1245
- validate: (v) => (v && v.length > 0 ? undefined : 'Private key is required'),
1246
- })
1247
- 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
1248
1305
  const installationId = await text({
1249
1306
  message: 'Installation ID (optional; leave blank to auto-discover)',
1250
1307
  validate: (v) =>
@@ -1255,17 +1312,11 @@ async function promptGithubAppAuth(): Promise<{
1255
1312
  return {
1256
1313
  type: 'app',
1257
1314
  appId: Number(appId),
1258
- privateKey: await resolveGithubPrivateKey(privateKeyInput),
1315
+ privateKey,
1259
1316
  ...(parsedInstallationId !== undefined ? { installationId: parsedInstallationId } : {}),
1260
1317
  }
1261
1318
  }
1262
1319
 
1263
- async function resolveGithubPrivateKey(input: string): Promise<string> {
1264
- const normalized = input.replace(/\\n/g, '\n')
1265
- if (normalized.includes('-----BEGIN') && normalized.includes('PRIVATE KEY-----')) return normalized
1266
- return await readFile(input, 'utf8')
1267
- }
1268
-
1269
1320
  function parseGithubRepos(input: string): string[] {
1270
1321
  return input
1271
1322
  .split(',')
@@ -1432,18 +1483,6 @@ function reportHatching(event: Extract<InitStepEvent, { step: 'hatching' }>): vo
1432
1483
  }
1433
1484
  }
1434
1485
 
1435
- export async function decideExistingApiKeyReuse(
1436
- provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
1437
- existingApiKey: string | null,
1438
- askReuse: (message: string) => Promise<unknown>,
1439
- ): Promise<'reuse' | 'prompt' | 'cancel'> {
1440
- if (!providerSupportsApiKey(provider) || existingApiKey === null) return 'prompt'
1441
-
1442
- const reuse = await askReuse(`Reuse existing ${provider.name} API key from secrets.json?`)
1443
- if (isCancel(reuse)) return 'cancel'
1444
- return reuse === true ? 'reuse' : 'prompt'
1445
- }
1446
-
1447
1486
  function uniqueProviders(options: ModelOption[]): KnownProviderId[] {
1448
1487
  const seen = new Set<KnownProviderId>()
1449
1488
  const out: KnownProviderId[] = []
@@ -0,0 +1,66 @@
1
+ // Pure controller for the inspect CLI's esc/ctrl-c key dispatch.
2
+ // Owns the AbortController lifecycle and the bare-ESC debounce timer,
3
+ // independent of process.stdin / TTY raw mode (which is wired in src/cli/inspect.ts).
4
+ // Extracted for testability: the lifecycle bug we want to pin is "armForStream's
5
+ // signal must remain valid across pause()/resume() cycles" — verifying that without
6
+ // a real TTY requires this seam.
7
+
8
+ export type EscChunkResult = { sigint: boolean }
9
+
10
+ export type EscController = {
11
+ armForStream: () => AbortSignal
12
+ onChunk: (chunk: Buffer) => EscChunkResult
13
+ clearPending: () => void
14
+ dispose: () => void
15
+ }
16
+
17
+ export function createEscController({ debounceMs }: { debounceMs: number }): EscController {
18
+ let currentCtrl: AbortController | null = null
19
+ let pendingEsc: ReturnType<typeof setTimeout> | null = null
20
+
21
+ const clearPending = (): void => {
22
+ if (pendingEsc !== null) {
23
+ clearTimeout(pendingEsc)
24
+ pendingEsc = null
25
+ }
26
+ }
27
+
28
+ return {
29
+ armForStream: () => {
30
+ clearPending()
31
+ currentCtrl = new AbortController()
32
+ return currentCtrl.signal
33
+ },
34
+ onChunk: (chunk) => {
35
+ if (chunk.length === 0) return { sigint: false }
36
+ if (chunk[0] === 0x03) {
37
+ // Ctrl-C in raw mode arrives as a byte (terminal driver does not generate
38
+ // SIGINT). Surface to the caller so it can re-issue SIGINT via the OS;
39
+ // we deliberately keep the AbortController lifecycle separate from SIGINT.
40
+ return { sigint: true }
41
+ }
42
+ if (chunk.length === 1 && chunk[0] === 0x1b) {
43
+ // Bare ESC: schedule the abort. A follow-up byte within debounceMs (CSI
44
+ // sequences from arrow keys, mouse, paste) cancels the pending fire.
45
+ // Snapshot currentCtrl so a late-firing timer can't abort a controller
46
+ // created by a subsequent armForStream() call.
47
+ clearPending()
48
+ const ctrl = currentCtrl
49
+ pendingEsc = setTimeout(() => {
50
+ pendingEsc = null
51
+ ctrl?.abort()
52
+ }, debounceMs)
53
+ return { sigint: false }
54
+ }
55
+ // Any other byte arriving within the ESC window is the second byte of a CSI
56
+ // sequence; cancel the pending abort.
57
+ clearPending()
58
+ return { sigint: false }
59
+ },
60
+ clearPending,
61
+ dispose: () => {
62
+ clearPending()
63
+ currentCtrl = null
64
+ },
65
+ }
66
+ }
@@ -5,6 +5,7 @@ import { findAgentDir } from '@/init'
5
5
  import { runInspectLoop, streamLive, type LiveSourceFactory, type SessionSummary } from '@/inspect'
6
6
  import { originLabel, shortSessionId } from '@/inspect/label'
7
7
 
8
+ import { createEscController } from './inspect-controller'
8
9
  import { cancel, c, errorLine, isCancel } from './ui'
9
10
 
10
11
  const ESC_LISTEN_DELAY_MS = 50
@@ -55,9 +56,9 @@ export const inspectCommand = defineCommand({
55
56
  ...(sinceArg !== undefined ? { since: sinceArg } : {}),
56
57
  json: isJson,
57
58
  color,
58
- selectSession: (sessions) => {
59
+ selectSession: (sessions, selectOpts) => {
59
60
  escListener?.pause()
60
- return clackSelect(sessions).finally(() => {
61
+ return clackSelect(sessions, selectOpts?.initialSessionId).finally(() => {
61
62
  escListener?.resume()
62
63
  })
63
64
  },
@@ -124,29 +125,12 @@ function createEscListener(): EscListener | null {
124
125
  const stdin = process.stdin
125
126
  if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return null
126
127
 
127
- let currentCtrl: AbortController | null = null
128
- let pendingEsc: ReturnType<typeof setTimeout> | null = null
128
+ const ctrl = createEscController({ debounceMs: ESC_LISTEN_DELAY_MS })
129
129
  let active = false
130
130
 
131
131
  const onData = (chunk: Buffer): void => {
132
- if (chunk.length === 0) return
133
- const first = chunk[0]
134
- if (first === 0x03) {
135
- process.kill(process.pid, 'SIGINT')
136
- return
137
- }
138
- if (chunk.length === 1 && first === 0x1b) {
139
- if (pendingEsc !== null) clearTimeout(pendingEsc)
140
- pendingEsc = setTimeout(() => {
141
- pendingEsc = null
142
- currentCtrl?.abort()
143
- }, ESC_LISTEN_DELAY_MS)
144
- return
145
- }
146
- if (pendingEsc !== null) {
147
- clearTimeout(pendingEsc)
148
- pendingEsc = null
149
- }
132
+ const { sigint } = ctrl.onChunk(chunk)
133
+ if (sigint) process.kill(process.pid, 'SIGINT')
150
134
  }
151
135
 
152
136
  const start = (): void => {
@@ -166,27 +150,28 @@ function createEscListener(): EscListener | null {
166
150
  /* terminal already torn down */
167
151
  }
168
152
  stdin.pause()
169
- if (pendingEsc !== null) {
170
- clearTimeout(pendingEsc)
171
- pendingEsc = null
172
- }
153
+ ctrl.clearPending()
173
154
  }
174
155
 
175
156
  return {
176
157
  armForStream: () => {
177
- currentCtrl = new AbortController()
158
+ const signal = ctrl.armForStream()
178
159
  start()
179
- return currentCtrl.signal
160
+ return signal
180
161
  },
181
162
  pause: () => {
182
163
  stop()
183
164
  },
184
165
  resume: () => {
185
- currentCtrl = new AbortController()
166
+ // Resume the listener WITHOUT replacing the AbortController.
167
+ // The signal returned by armForStream() is held by the live source
168
+ // through streamSession's combinedSignal; replacing the controller
169
+ // here would orphan that signal so a subsequent ESC press could
170
+ // not abort the live tail.
186
171
  start()
187
172
  },
188
173
  stop: () => {
189
- currentCtrl = null
174
+ ctrl.dispose()
190
175
  stop()
191
176
  },
192
177
  }
@@ -204,8 +189,15 @@ function useColor(): boolean {
204
189
  return Boolean(process.stdout.isTTY)
205
190
  }
206
191
 
207
- async function clackSelect(sessions: SessionSummary[]): Promise<SessionSummary | null> {
192
+ async function clackSelect(
193
+ sessions: SessionSummary[],
194
+ initialSessionId: string | undefined,
195
+ ): Promise<SessionSummary | null> {
208
196
  const { select } = await import('@clack/prompts')
197
+ const preferred =
198
+ initialSessionId !== undefined && sessions.some((s) => s.sessionId === initialSessionId)
199
+ ? initialSessionId
200
+ : sessions[0]?.sessionId
209
201
  const picked = await select<string>({
210
202
  message: `Pick a session to inspect (showing ${sessions.length})`,
211
203
  options: sessions.map((s) => ({
@@ -213,7 +205,7 @@ async function clackSelect(sessions: SessionSummary[]): Promise<SessionSummary |
213
205
  label: formatRowLabel(s),
214
206
  ...(s.firstPrompt !== null ? { hint: truncate(s.firstPrompt, 60) } : { hint: '(no prompt)' }),
215
207
  })),
216
- initialValue: sessions[0]?.sessionId,
208
+ initialValue: preferred,
217
209
  })
218
210
  if (isCancel(picked)) {
219
211
  cancel('Cancelled.')